diff --git a/package-lock.json b/package-lock.json index 154bd116e..5134062be 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@treeseed/api", - "version": "0.8.0-rc.11", + "version": "0.8.0-rc.12", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@treeseed/api", - "version": "0.8.0-rc.11", + "version": "0.8.0-rc.12", "license": "AGPL-3.0-only", "dependencies": { "@modelcontextprotocol/core": "2.0.0", @@ -14,7 +14,7 @@ "@modelcontextprotocol/server": "2.0.0", "@octokit/auth-app": "^8.2.0", "@react-email/render": "^2.0.8", - "@treeseed/sdk": "0.13.0-rc.27", + "@treeseed/sdk": "0.13.0-rc.29", "@treeseed/treedx": "0.3.0-rc.3", "drizzle-orm": "^0.45.2", "hono": "4.13.3", @@ -1226,9 +1226,9 @@ "license": "MIT" }, "node_modules/@treeseed/sdk": { - "version": "0.13.0-rc.27", - "resolved": "https://registry.npmjs.org/@treeseed/sdk/-/sdk-0.13.0-rc.27.tgz", - "integrity": "sha512-DOKY8Ahn7VxfHNqNU1pDUYFjWPn1X98jdSZPwrTy17TaZicSzeOtQ0cR7atGcBzzUBi5NwawzKJHwjWrZy2SeQ==", + "version": "0.13.0-rc.29", + "resolved": "https://registry.npmjs.org/@treeseed/sdk/-/sdk-0.13.0-rc.29.tgz", + "integrity": "sha512-eaBoFWYBIYsKZBYuTZ4Q4HGgktpuHI5FgHg5b1tJEXYBL3VMRQ0spmA0PLFwHDJ0+UDBsj950GRK1lk/jWMayQ==", "license": "Apache-2.0", "dependencies": { "@treeseed/treedx": "0.3.0-rc.4", diff --git a/package.json b/package.json index 84951c3d0..5c4abf3b8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@treeseed/api", - "version": "0.8.0-rc.11", + "version": "0.8.0-rc.12", "description": "TreeSeed control-plane API and operations runner.", "license": "AGPL-3.0-only", "repository": { @@ -67,7 +67,7 @@ "@modelcontextprotocol/server": "2.0.0", "@octokit/auth-app": "^8.2.0", "@react-email/render": "^2.0.8", - "@treeseed/sdk": "0.13.0-rc.27", + "@treeseed/sdk": "0.13.0-rc.29", "@treeseed/treedx": "0.3.0-rc.3", "drizzle-orm": "^0.45.2", "hono": "4.13.3", diff --git a/scripts/release/create-component-release.ts b/scripts/release/create-component-release.ts index 78e6b636d..7613e445a 100644 --- a/scripts/release/create-component-release.ts +++ b/scripts/release/create-component-release.ts @@ -1,3 +1,4 @@ +import { createHash } from 'node:crypto'; import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'; import { resolve } from 'node:path'; import { componentReleaseSchema, deploymentDigest } from '@treeseed/sdk/deployment'; @@ -7,21 +8,26 @@ const apiDigest = process.env.TREESEED_API_DIGEST, runnerDigest = process.env.TR if (!release || !sourceCommit || !apiDigest || !runnerDigest || !databaseDigest) throw new Error('Release, exact source commit, and every multi-architecture image digest are required.'); const digest = /^sha256:[a-f0-9]{64}$/u; if (!/^[a-f0-9]{40}$/u.test(sourceCommit) || ![apiDigest, runnerDigest, databaseDigest].every((value) => digest.test(value))) throw new Error('Source or image digest is malformed.'); -const track = release.includes('-rc.') ? 'development' : 'stable', debianRelease = release.replace(/-rc\.(\d+)$/u, '~rc$1'); +const track = release.includes('-rc.') ? 'development' : 'stable', revision = Number(process.env.TREESEED_COMPONENT_REVISION ?? '1'); +if (!Number.isInteger(revision) || revision < 1) throw new Error('Component revision must be a positive integer.'); +const debianRelease = `${release.replace(/-rc\.(\d+)$/u, '~rc$1')}-${revision}`; +const compose = readFileSync(resolve('deploy/compose.template.yml'), 'utf8').replaceAll('@API_IMAGE@', `treeseed/api@${apiDigest}`).replace('@RUNNER_IMAGE@', `treeseed/op-runner@${runnerDigest}`).replace('@DATABASE_IMAGE@', `treeseed/api-postgres@${databaseDigest}`); +if (/\bbuild\s*:/u.test(compose) || /@[A-Z_]+@/u.test(compose)) throw new Error('Production Compose bundle is not fully materialized.'); +const composeDigest = `sha256:${createHash('sha256').update(compose).digest('hex')}`; const runtime = { schemaVersion: 'treeseed.package-runtime/v1' as const, componentId: 'api', version: debianRelease, - compose: { projectName: 'treeseed-api', files: ['compose.yml'] }, + compose: { projectName: 'treeseed-api', files: [{ path: 'compose.yml', digest: composeDigest }] }, services: [ { id: 'database', composeService: 'database', endpoints: [] }, { id: 'migration', composeService: 'migration', endpoints: [] }, { id: 'api', composeService: 'api', endpoints: [{ id: 'http', protocol: 'http' as const, port: 3000, visibility: 'host' as const, defaultAlias: 'api.treeseed.localhost', aliasOverride: true, tls: 'edge' as const, authentication: 'application' as const, healthGate: { protocol: 'http' as const, path: '/v1/health/ready', timeoutSeconds: 120 } }] }, { id: 'operations-runner', composeService: 'operations-runner', endpoints: [] }, ], stateVolumes: [{ id: 'postgres', volume: '/var/lib/treeseed/components/api/postgres', backup: 'required' as const }, { id: 'operations-runner', volume: '/var/lib/treeseed/components/api/operations-runner', backup: 'required' as const }], - migrations: [{ id: 'control-plane-postgres', order: 0, backupRequired: true }], requiredCapabilities: ['docker-compose'], + migrations: [{ id: 'control-plane-postgres', order: 0, backupRequired: true }], requiredCapabilities: ['docker-compose'], dependencies: [], }; const tagUrl = (repository: string) => `https://hub.docker.com/r/${repository}/tags?name=${encodeURIComponent(release)}`; const bundle = componentReleaseSchema.parse({ - schemaVersion: 'treeseed.component-release/v1', componentId: 'api', release: debianRelease, track, + schemaVersion: 'treeseed.component-release/v1', componentId: 'api', release: debianRelease, applicationVersion: release, revision, track, source: { repository: 'treeseed-ai/api', commit: sourceCommit }, stableBase: track === 'development' ? { releaseRange: '>=0.1.0 <0.2.0', compatibilityId: 'treeseed-linux-amd64-v1', catalogDigest: null } : null, packages: [{ name: 'treeseed-component-api', version: debianRelease, architecture: 'all', origin: 'TreeSeed Deployment', order: 20 }], @@ -34,7 +40,5 @@ const bundle = componentReleaseSchema.parse({ evidence: { provenance: [tagUrl('treeseed/api'), tagUrl('treeseed/op-runner'), tagUrl('treeseed/api-postgres')], sboms: [tagUrl('treeseed/api'), tagUrl('treeseed/op-runner'), tagUrl('treeseed/api-postgres')], vulnerabilities: [] }, }); const output = resolve('release-assets'); mkdirSync(output, { recursive: true }); -const compose = readFileSync(resolve('deploy/compose.template.yml'), 'utf8').replaceAll('@API_IMAGE@', `treeseed/api@${apiDigest}`).replace('@RUNNER_IMAGE@', `treeseed/op-runner@${runnerDigest}`).replace('@DATABASE_IMAGE@', `treeseed/api-postgres@${databaseDigest}`); -if (/\bbuild\s*:/u.test(compose) || /@[A-Z_]+@/u.test(compose)) throw new Error('Production Compose bundle is not fully materialized.'); writeFileSync(resolve(output, 'compose.yml'), compose); writeFileSync(resolve(output, 'component-release.json'), `${JSON.stringify(bundle, null, 2)}\n`); console.log(JSON.stringify({ ok: true, release, sourceCommit, apiDigest, runnerDigest, databaseDigest, runtimeDigest: bundle.runtimeDigest })); diff --git a/tests/unit/control-plane/release/publication.test.ts b/tests/unit/control-plane/release/publication.test.ts index b0c687cbc..3b7c3d066 100644 --- a/tests/unit/control-plane/release/publication.test.ts +++ b/tests/unit/control-plane/release/publication.test.ts @@ -8,8 +8,8 @@ afterEach(() => rmSync('release-assets', { recursive: true, force: true })); describe('managed API release publication', () => { it('accepts the exact package RC tag and rejects aliases or build metadata', () => { - execFileSync(process.execPath, ['--import', 'tsx', 'scripts/packages/assert-release-tag-version.ts'], { env: { ...process.env, GITHUB_REF_NAME: '0.8.0-rc.11' } }); - for (const tag of ['v0.8.0-rc.11', '0.8.0-rc.11+rebuilt']) { + execFileSync(process.execPath, ['--import', 'tsx', 'scripts/packages/assert-release-tag-version.ts'], { env: { ...process.env, GITHUB_REF_NAME: '0.8.0-rc.12' } }); + for (const tag of ['v0.8.0-rc.12', '0.8.0-rc.12+rebuilt']) { const result = spawnSync(process.execPath, ['--import', 'tsx', 'scripts/packages/assert-release-tag-version.ts'], { env: { ...process.env, GITHUB_REF_NAME: tag } }); expect(result.status).not.toBe(0); } @@ -28,7 +28,7 @@ describe('managed API release publication', () => { it('materializes exact production images without a source build or host port', () => { execFileSync(process.execPath, ['--import', 'tsx', 'scripts/release/create-component-release.ts'], { env: { ...process.env, TREESEED_RELEASE: '0.8.0-rc.8', TREESEED_SOURCE_COMMIT: 'a'.repeat(40), TREESEED_API_DIGEST: hash('b'), TREESEED_RUNNER_DIGEST: hash('c'), TREESEED_DATABASE_DIGEST: hash('d') } }); const compose = readFileSync('release-assets/compose.yml', 'utf8'); - const bundle = JSON.parse(readFileSync('release-assets/component-release.json', 'utf8')) as { track: string; stableBase: { catalogDigest: unknown }; images: unknown[] }; + const bundle = JSON.parse(readFileSync('release-assets/component-release.json', 'utf8')) as { release: string; revision: number; runtime: { compose: { files: Array<{ path: string; digest: string }> } }; track: string; stableBase: { catalogDigest: unknown }; images: unknown[] }; expect(compose).not.toMatch(/\bbuild\s*:/u); expect(compose).not.toMatch(/^\s+ports:/mu); expect(compose).toContain(`treeseed/api@${hash('b')}`); @@ -39,5 +39,8 @@ describe('managed API release publication', () => { expect(bundle.images).toHaveLength(3); expect(bundle.track).toBe('development'); expect(bundle.stableBase.catalogDigest).toBeNull(); + expect(bundle.release).toBe('0.8.0~rc8-1'); + expect(bundle.revision).toBe(1); + expect(bundle.runtime.compose.files).toEqual([{ path: 'compose.yml', digest: expect.stringMatching(/^sha256:[a-f0-9]{64}$/u) }]); }); });