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
7 changes: 6 additions & 1 deletion .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
Expand Down
237 changes: 237 additions & 0 deletions scripts/verify-release-versions.mjs
Original file line number Diff line number Diff line change
@@ -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;
}
}
113 changes: 113 additions & 0 deletions scripts/verify-release-versions.spec.mjs
Original file line number Diff line number Diff line change
@@ -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".'
);
});
});
Loading