Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 73 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
# CI pipeline for the OpenDecree TypeScript SDK.
#
# Jobs: lint, typecheck, test (matrix: Node 20/22/24), examples → check (alls-green gate)
# Jobs: lint, typecheck, test (matrix: Node 20/22/24), examples, integration → check (alls-green gate)
# The check job aggregates all results for branch protection.
#
# The first job defines YAML anchors (&checkout, &setup-node-22, &install)
# on its setup steps; subsequent jobs alias them to avoid repetition.
# The test job uses its own setup-node step because the matrix node-version
# varies per run.
#
# The integration job checks out the decree server repo, builds the server
# and tools images, then runs the Vitest integration suite against a
# docker-compose stack (postgres + redis + decree).

name: CI

Expand Down Expand Up @@ -97,10 +101,77 @@ jobs:
- name: Typecheck examples
run: cd examples && npx tsc --noEmit

integration:
name: Integration
runs-on: ubuntu-latest
timeout-minutes: 20
permissions:
contents: read
packages: read
steps:
- *checkout
- *setup-node-22
- *install

- name: Checkout decree server
uses: actions/checkout@v6
with:
repository: opendecree/decree
path: _decree
persist-credentials: false

- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
with:
driver: docker-container

- name: Log in to ghcr.io
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

- name: Build tools image
uses: docker/build-push-action@v7
with:
context: _decree
file: _decree/build/Dockerfile.tools
load: true
tags: decree-tools
cache-from: type=registry,ref=ghcr.io/opendecree/decree-tools:buildcache
cache-to: type=local,dest=/tmp/.buildx-cache-tools,mode=max

- name: Build server image
uses: docker/build-push-action@v7
with:
context: _decree
file: _decree/build/Dockerfile
load: true
tags: decree-server
cache-from: type=registry,ref=ghcr.io/opendecree/decree:buildcache
cache-to: type=local,dest=/tmp/.buildx-cache-server,mode=max

- name: Start decree stack
run: docker compose up -d --wait service
env:
DECREE_SRC: ./_decree
TOOLS_IMAGE: decree-tools
SERVICE_IMAGE: decree-server

- name: Run integration tests
run: npm run test:integration

- name: Tear down decree stack
if: always()
run: docker compose down -v
env:
DECREE_SRC: ./_decree

check:
name: CI check
if: always()
needs: [lint, typecheck, test, examples, version]
needs: [lint, typecheck, test, examples, version, integration]
runs-on: ubuntu-latest
steps:
- uses: re-actors/alls-green@release/v1
Expand Down
82 changes: 82 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
# Integration test fixture — starts a minimal decree server stack.
#
# Usage (local):
# Requires decree repo checked out at ../decree (decree-workspace layout).
# cd decree-typescript
# docker build -t decree-tools -f ../decree/build/Dockerfile.tools ../decree
# docker build -t decree-server -f ../decree/build/Dockerfile ../decree
# docker compose up -d --wait service
# DECREE_INTEGRATION=1 npm run test:integration
# docker compose down -v
#
# CI sets DECREE_SRC=./_decree, TOOLS_IMAGE, and SERVICE_IMAGE via env vars before running.
# Note: DECREE_SRC must begin with ./ or / to be treated as a bind-mount path.

services:
postgres:
image: postgres:17
environment:
POSTGRES_DB: centralconfig
POSTGRES_USER: centralconfig
POSTGRES_PASSWORD: localdev
ports:
- "5432:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U centralconfig"]
interval: 2s
timeout: 5s
retries: 10

redis:
image: redis:7
command: redis-server --maxmemory 128mb --maxmemory-policy allkeys-lru
ports:
- "6379:6379"
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 2s
timeout: 5s
retries: 10

migrate:
image: ${TOOLS_IMAGE:-decree-tools}
depends_on:
postgres:
condition: service_healthy
volumes:
- ${DECREE_SRC:-../decree}/db/migrations:/migrations:ro
command:
[
"goose",
"-dir",
"/migrations",
"postgres",
"postgres://centralconfig:localdev@postgres:5432/centralconfig?sslmode=disable",
"up",
]

service:
image: ${SERVICE_IMAGE:-decree-server}
build:
context: ${DECREE_SRC:-../decree}
dockerfile: build/Dockerfile
depends_on:
migrate:
condition: service_completed_successfully
redis:
condition: service_healthy
environment:
GRPC_PORT: "9090"
DB_WRITE_URL: "postgres://centralconfig:localdev@postgres:5432/centralconfig?sslmode=disable"
DB_READ_URL: "postgres://centralconfig:localdev@postgres:5432/centralconfig?sslmode=disable"
REDIS_URL: "redis://redis:6379"
ENABLE_SERVICES: "schema,config"
HTTP_PORT: "8080"
INSECURE_LISTEN: "1"
ENABLE_REFLECTION: "1"
ports:
- "9090:9090"
- "8080:8080"

volumes:
pgdata:
137 changes: 137 additions & 0 deletions integration/setup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
/**
* Global setup for integration tests.
*
* Creates a schema + tenant before all tests, sets initial values, then
* tears everything down afterward. The tenantId and serverAddr are
* provided to tests via Vitest's inject() mechanism.
*/

import { credentials, Metadata, type ServiceError } from "@grpc/grpc-js";
import type { GlobalSetupContext } from "vitest/node";
import { ConfigClient } from "../src/client.js";
import { ConfigServiceClient } from "../src/generated/centralconfig/v1/config_service.js";
import { SchemaServiceClient } from "../src/generated/centralconfig/v1/schema_service.js";
import { FieldType } from "../src/generated/centralconfig/v1/types.js";

declare module "vitest" {
interface ProvidedContext {
tenantId: string;
schemaId: string;
serverAddr: string;
}
}

function promisify<Req, Res>(
fn: (req: Req, meta: Metadata, cb: (err: ServiceError | null, res: Res) => void) => void,
req: Req,
meta: Metadata,
): Promise<Res> {
return new Promise((resolve, reject) => {
fn(req, meta, (err, res) => {
if (err) reject(err);
else resolve(res as Res);
});
});
}

export async function setup({ provide }: GlobalSetupContext) {
const serverAddr = process.env.DECREE_SERVER_ADDR ?? "localhost:9090";
const creds = credentials.createInsecure();

const meta = new Metadata();
meta.set("x-subject", "integration-test");
meta.set("x-role", "superadmin");

const schemaClient = new SchemaServiceClient(serverAddr, creds);

// Wait up to 30s for the server to accept gRPC connections.
await new Promise<void>((resolve, reject) => {
schemaClient.waitForReady(new Date(Date.now() + 30_000), (err) => {
if (err) reject(new Error(`server not ready at ${serverAddr}: ${err.message}`));
else resolve();
});
});

const schemaName = `ts-sdk-int-${Date.now()}`;
const { schema } = await promisify(
schemaClient.createSchema.bind(schemaClient),
{
name: schemaName,
description: "TypeScript SDK integration test schema",
fields: [
{
path: "app.fee",
type: FieldType.FIELD_TYPE_STRING,
constraints: undefined,
nullable: true,
deprecated: false,
examples: {},
tags: [],
},
{
path: "app.count",
type: FieldType.FIELD_TYPE_INT,
constraints: undefined,
nullable: false,
deprecated: false,
examples: {},
tags: [],
},
{
path: "app.enabled",
type: FieldType.FIELD_TYPE_BOOL,
constraints: undefined,
nullable: false,
deprecated: false,
examples: {},
tags: [],
},
],
},
meta,
);
if (!schema) throw new Error("createSchema returned no schema");
const schemaId = schema.id;

await promisify(
schemaClient.publishSchema.bind(schemaClient),
{ id: schemaId, version: 1 },
meta,
);

const tenantName = `ts-sdk-tenant-${Date.now()}`;
const { tenant } = await promisify(
schemaClient.createTenant.bind(schemaClient),
{ name: tenantName, schemaId, schemaVersion: 1 },
meta,
);
if (!tenant) throw new Error("createTenant returned no tenant");
const tenantId = tenant.id;

const configClient = new ConfigClient(serverAddr, {
insecure: true,
subject: "integration-test",
role: "superadmin",
retry: false,
});
const rawConfig = new ConfigServiceClient(serverAddr, creds);
await configClient.set(tenantId, "app.fee", "0.5%");
await promisify(
rawConfig.setField.bind(rawConfig),
{ tenantId, fieldPath: "app.count", value: { integerValue: 42 } },
meta,
);
await configClient.setBool(tenantId, "app.enabled", true);
configClient.close();
rawConfig.close();

provide("tenantId", tenantId);
provide("schemaId", schemaId);
provide("serverAddr", serverAddr);

return async () => {
await promisify(schemaClient.deleteTenant.bind(schemaClient), { id: tenantId }, meta);
await promisify(schemaClient.deleteSchema.bind(schemaClient), { id: schemaId }, meta);
schemaClient.close();
};
}
Loading