From 81558d98366f2efbf29627ce67bff24a56171b3d Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sat, 2 May 2026 09:06:38 -0700 Subject: [PATCH] ci(release): enforce atomic package versions --- .github/workflows/publish.yml | 7 +- scripts/verify-release-versions.mjs | 237 +++++++++++++++++++++++ scripts/verify-release-versions.spec.mjs | 113 +++++++++++ 3 files changed, 356 insertions(+), 1 deletion(-) create mode 100644 scripts/verify-release-versions.mjs create mode 100644 scripts/verify-release-versions.spec.mjs diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index b8f8d3ce4..958417cd2 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -16,7 +16,7 @@ jobs: runs-on: ubuntu-latest permissions: contents: read - id-token: write # required for npm trusted publishing + provenance + id-token: write # required for npm trusted publishing + provenance env: NPM_PUBLISHABLE_PROJECTS: chat,langgraph,ag-ui,render,a2ui,partial-json,licensing steps: @@ -43,6 +43,11 @@ jobs: - name: Lint, test, build publishable projects run: npx nx run-many -t lint,test,build --projects=$NPM_PUBLISHABLE_PROJECTS --skip-nx-cache + - name: Verify atomic release versions + run: node scripts/verify-release-versions.mjs --tag "$RELEASE_TAG" + env: + RELEASE_TAG: ${{ startsWith(github.ref, 'refs/tags/') && github.ref_name || '' }} + # Trusted publishing is configured per-package on npm; no NPM_TOKEN needed. # The OIDC token from id-token: write authenticates this workflow as a # trusted publisher for each @ngaf/* package. Provenance attestations are diff --git a/scripts/verify-release-versions.mjs b/scripts/verify-release-versions.mjs new file mode 100644 index 000000000..975054e73 --- /dev/null +++ b/scripts/verify-release-versions.mjs @@ -0,0 +1,237 @@ +#!/usr/bin/env node +import { readdir, readFile } from 'node:fs/promises'; +import { join, relative } from 'node:path'; +import { pathToFileURL } from 'node:url'; + +const SKIPPED_DIRECTORIES = new Set([ + '.git', + '.nx', + 'coverage', + 'dist', + 'node_modules', + 'tmp', +]); + +async function readJson(path) { + return JSON.parse(await readFile(path, 'utf8')); +} + +async function* walkProjectJsonFiles(directory) { + for (const entry of await readdir(directory, { withFileTypes: true })) { + if (entry.isDirectory()) { + if (!SKIPPED_DIRECTORIES.has(entry.name)) { + yield* walkProjectJsonFiles(join(directory, entry.name)); + } + continue; + } + + if (entry.isFile() && entry.name === 'project.json') { + yield join(directory, entry.name); + } + } +} + +async function findProjects(workspaceRoot) { + const projects = new Map(); + + for await (const projectJsonPath of walkProjectJsonFiles(workspaceRoot)) { + const project = await readJson(projectJsonPath); + if (typeof project.name === 'string') { + projects.set(project.name, { + name: project.name, + root: projectJsonPath.slice(0, -'/project.json'.length), + targets: project.targets ?? {}, + }); + } + } + + return projects; +} + +async function getPackageForProject(workspaceRoot, project) { + const packageJsonPath = join(project.root, 'package.json'); + const packageJson = await readJson(packageJsonPath); + + return { + packageJson, + packageJsonPath, + packageName: packageJson.name, + projectName: project.name, + version: packageJson.version, + }; +} + +function normalizeTag(tag) { + if (!tag) { + return undefined; + } + + return tag.replace(/^refs\/tags\//, ''); +} + +export async function verifyReleaseVersions({ + workspaceRoot = process.cwd(), + groupName = 'publishable', + expectedTag, +} = {}) { + const nxJson = await readJson(join(workspaceRoot, 'nx.json')); + const releaseGroup = nxJson.release?.groups?.[groupName]; + + if (!releaseGroup) { + throw new Error( + `Release group "${groupName}" is not configured in nx.json.` + ); + } + + if (releaseGroup.projectsRelationship !== 'fixed') { + throw new Error( + `Release group "${groupName}" must use projectsRelationship "fixed" for atomic releases.` + ); + } + + const projectNames = releaseGroup.projects; + if (!Array.isArray(projectNames) || projectNames.length === 0) { + throw new Error(`Release group "${groupName}" does not list any projects.`); + } + + const projects = await findProjects(workspaceRoot); + const releaseProjectNames = new Set(projectNames); + const omittedPublicPackages = []; + + for (const project of projects.values()) { + let packageInfo; + + try { + packageInfo = await getPackageForProject(workspaceRoot, project); + } catch (error) { + if (error?.code === 'ENOENT') { + continue; + } + throw error; + } + + if ( + packageInfo.packageJson.private !== true && + typeof packageInfo.packageName === 'string' && + packageInfo.packageName.startsWith('@ngaf/') && + project.targets['nx-release-publish'] && + !releaseProjectNames.has(project.name) + ) { + omittedPublicPackages.push(packageInfo.packageName); + } + } + + if (omittedPublicPackages.length > 0) { + omittedPublicPackages.sort((a, b) => a.localeCompare(b)); + throw new Error( + omittedPublicPackages + .map( + (packageName) => + `Public package ${packageName} is not included in release group "${groupName}".` + ) + .join('\n') + ); + } + + const packages = []; + + for (const projectName of projectNames) { + const project = projects.get(projectName); + if (!project) { + throw new Error( + `Release project "${projectName}" does not have a project.json.` + ); + } + + const packageInfo = await getPackageForProject(workspaceRoot, project); + + if (packageInfo.packageJson.private === true) { + throw new Error( + `Release project "${projectName}" points at private package ${relative( + workspaceRoot, + packageInfo.packageJsonPath + )}.` + ); + } + + if ( + typeof packageInfo.packageName !== 'string' || + typeof packageInfo.version !== 'string' + ) { + throw new Error( + `Release project "${projectName}" must have package name and version in ${relative( + workspaceRoot, + packageInfo.packageJsonPath + )}.` + ); + } + + packages.push({ + packageName: packageInfo.packageName, + projectName: packageInfo.projectName, + version: packageInfo.version, + }); + } + + packages.sort((a, b) => a.packageName.localeCompare(b.packageName)); + + const versions = new Set(packages.map((pkg) => pkg.version)); + if (versions.size !== 1) { + const packageList = packages + .map((pkg) => ` - ${pkg.packageName}: ${pkg.version}`) + .join('\n'); + + throw new Error( + `Release group "${groupName}" must publish atomically with one uniform version.\n${packageList}` + ); + } + + const [version] = versions; + const tag = normalizeTag(expectedTag); + + if (tag && tag !== `v${version}`) { + throw new Error( + `Release tag ${tag} does not match package version ${version}.` + ); + } + + return { + version, + packages: packages.map((pkg) => pkg.packageName), + }; +} + +function parseArgs(argv) { + const options = {}; + + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + if (arg === '--group') { + options.groupName = argv[index + 1]; + index += 1; + } else if (arg === '--tag') { + options.expectedTag = argv[index + 1]; + index += 1; + } else { + throw new Error(`Unknown argument: ${arg}`); + } + } + + return options; +} + +if (import.meta.url === pathToFileURL(process.argv[1]).href) { + try { + const result = await verifyReleaseVersions( + parseArgs(process.argv.slice(2)) + ); + console.log( + `Release group "publishable" is atomic at ${ + result.version + }: ${result.packages.join(', ')}` + ); + } catch (error) { + console.error(error instanceof Error ? error.message : error); + process.exitCode = 1; + } +} diff --git a/scripts/verify-release-versions.spec.mjs b/scripts/verify-release-versions.spec.mjs new file mode 100644 index 000000000..c3d269d2e --- /dev/null +++ b/scripts/verify-release-versions.spec.mjs @@ -0,0 +1,113 @@ +import { mkdir, mkdtemp, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; + +import { describe, expect, it } from 'vitest'; + +import { verifyReleaseVersions } from './verify-release-versions.mjs'; + +async function writeJson(path, value) { + await writeFile(path, `${JSON.stringify(value, null, 2)}\n`); +} + +async function createWorkspace(projectVersions) { + const workspaceRoot = await mkdtemp(join(tmpdir(), 'ngaf-release-versions-')); + const projects = Object.keys(projectVersions); + + await writeJson(join(workspaceRoot, 'nx.json'), { + release: { + groups: { + publishable: { + projects, + projectsRelationship: 'fixed', + }, + }, + }, + }); + + for (const [projectName, version] of Object.entries(projectVersions)) { + const projectRoot = join(workspaceRoot, 'libs', projectName); + await mkdir(projectRoot, { recursive: true }); + await writeJson(join(projectRoot, 'project.json'), { + name: projectName, + }); + await writeJson(join(projectRoot, 'package.json'), { + name: `@ngaf/${projectName}`, + version, + }); + } + + return workspaceRoot; +} + +describe('verifyReleaseVersions', () => { + it('accepts a fixed release group where every package shares the same version', async () => { + const workspaceRoot = await createWorkspace({ + chat: '0.0.13', + langgraph: '0.0.13', + render: '0.0.13', + }); + + await expect( + verifyReleaseVersions({ workspaceRoot, expectedTag: 'v0.0.13' }) + ).resolves.toEqual({ + version: '0.0.13', + packages: ['@ngaf/chat', '@ngaf/langgraph', '@ngaf/render'], + }); + }); + + it('rejects release groups with mixed package versions', async () => { + const workspaceRoot = await createWorkspace({ + chat: '0.0.13', + langgraph: '0.0.12', + render: '0.0.13', + }); + + await expect( + verifyReleaseVersions({ workspaceRoot, expectedTag: 'v0.0.13' }) + ).rejects.toThrow( + 'Release group "publishable" must publish atomically with one uniform version.' + ); + }); + + it('rejects a release tag that does not match the uniform package version', async () => { + const workspaceRoot = await createWorkspace({ + chat: '0.0.13', + langgraph: '0.0.13', + render: '0.0.13', + }); + + await expect( + verifyReleaseVersions({ workspaceRoot, expectedTag: 'v0.0.12' }) + ).rejects.toThrow( + 'Release tag v0.0.12 does not match package version 0.0.13.' + ); + }); + + it('rejects public packages that are missing from the release group', async () => { + const workspaceRoot = await createWorkspace({ + chat: '0.0.13', + langgraph: '0.0.13', + }); + const projectRoot = join(workspaceRoot, 'libs', 'render'); + await mkdir(projectRoot, { recursive: true }); + await writeJson(join(projectRoot, 'project.json'), { + name: 'render', + targets: { + 'nx-release-publish': { + options: { + packageRoot: 'dist/libs/render', + }, + }, + }, + }); + await writeJson(join(projectRoot, 'package.json'), { + name: '@ngaf/render', + version: '0.0.13', + }); + + await expect(verifyReleaseVersions({ workspaceRoot })).rejects.toThrow( + 'Public package @ngaf/render is not included in release group "publishable".' + ); + }); +});