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
337 changes: 312 additions & 25 deletions .github/workflows/ci.yml

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@
"clean:native": "bun scripts/clean.ts --native",
"test": "bun run --parallel test:ts test:rs",
"test:ts": "bun run test:release && bun run --workspaces --if-present test",
"test:release": "bun test scripts/clean.test.ts scripts/generate-gjc-sdk-skills.test.ts scripts/nightly-release.test.ts scripts/release-evidence.test.ts scripts/release-policy.test.ts scripts/release-publish-order.test.ts scripts/restart-sdk-broker.test.ts",
"test:release": "bun test scripts/clean.test.ts scripts/generate-gjc-sdk-skills.test.ts scripts/nightly-release.test.ts scripts/release-evidence.test.ts scripts/release-notes.test.ts scripts/release-policy.test.ts scripts/release-publish-order.test.ts scripts/release-retry.test.ts scripts/restart-sdk-broker.test.ts",
"generate-schemas": "bun scripts/generate-json-schemas.ts",
"check:schemas": "bun scripts/generate-json-schemas.ts --check",
"check:public-sync": "bun run generate-docs-index && bun scripts/check-public-version-sync.ts",
Expand Down
38 changes: 38 additions & 0 deletions scripts/check-node20-baseline.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,44 @@ jobs:
expect(violations).toEqual([]);
});

test("accepts an intentionally pinned exact Node 24 patch for credential-bearing release jobs", async () => {
const root = await createRepo({
".github/workflows/ci.yml": `name: CI
jobs:
publish:
steps:
- uses: actions/setup-node@v4
with:
node-version: "24.19.0"
- run: bun run ci:release:publish
`,
});

const violations = await checkNode20Baseline(root);

expect(violations).toEqual([]);
});

test("still rejects floating ranges and non-exact Node pins on release jobs", async () => {
for (const version of ["24.x", "24.19", "^24.19.0", "~24.19.0", "25.0.0", "20"]) {
const root = await createRepo({
".github/workflows/ci.yml": `name: CI
jobs:
publish:
steps:
- uses: actions/setup-node@v4
with:
node-version: "${version}"
- run: bun run ci:release:publish
`,
});

const violations = await checkNode20Baseline(root);

expect(violations, `node-version "${version}" must stay a violation`).not.toEqual([]);
}
});

test("allows released changelog history and historical fixtures", async () => {
const root = await createRepo({
"packages/ai/CHANGELOG.md": `# Changelog
Expand Down
10 changes: 8 additions & 2 deletions scripts/check-node20-baseline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -193,12 +193,18 @@ function setupNodeStepViolations(relativePath: string, job: WorkflowJob): Baseli
}

const nodeVersion = setupNodeVersion(stepLines, stepIndent);
if (nodeVersion === "24") continue;
// Repository policy: release-capable jobs pin the Node 24 line. The bare
// "24" pin satisfies the baseline; an intentionally pinned exact
// 24.<minor>.<patch> is also accepted for credential-bearing release
// jobs (e.g. the OIDC publish job), where a floating bootstrap runtime
// inside the id-token boundary is a security regression, not hygiene.
// Ranges, other majors, and omitted versions stay violations.
if (nodeVersion === "24" || /^24\.\d+\.\d+$/u.test(nodeVersion ?? "")) continue;

violations.push({
path: relativePath,
line: job.startLine + index,
message: `Release-capable job '${job.name}' uses actions/setup-node but does not pin node-version: "24".`,
message: `Release-capable job '${job.name}' uses actions/setup-node but does not pin node-version: "24" (or an exact 24.x.y patch for credential-bearing jobs).`,
snippet: line.trim(),
});
}
Expand Down
16 changes: 9 additions & 7 deletions scripts/check-workflow-permissions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,23 +43,25 @@ describe("workflow permission policy", () => {
]);
});

test("ci.yml has an exact read-scoped workflow default and one write job", async () => {
test("ci.yml has an exact read-scoped workflow default and two write jobs", async () => {
const workflows = await readWorkflowDocuments();
const ci = workflows.find(workflow => workflow.file === CI_WORKFLOW);
expect(ci).toBeDefined();
const document = documentRecord(ci!.document);

expect(REQUIRED_READ_DEFAULT).toContain(CI_WORKFLOW);
expect(document.permissions).toEqual({ contents: "read" });
// publish is the only job allowed to escalate: contents for the GitHub
// Release, and id-token for npm trusted publishing (OIDC), which is what
// keeps a long-lived registry credential out of the release path.
// Exactly two jobs may escalate, with disjoint capabilities:
// release_finalize holds contents for the GitHub Release (no OIDC), and
// publish holds id-token for npm trusted publishing (no repository
// scope) — which keeps a long-lived registry credential out of the
// release path.
expect(JOB_WRITE_ALLOWLIST).toEqual([
{ workflow: CI_WORKFLOW, job: "publish", scope: "contents" },
{ workflow: CI_WORKFLOW, job: "release_finalize", scope: "contents" },
{ workflow: CI_WORKFLOW, job: "publish", scope: "id-token" },
{ workflow: PR_VALIDATION_WORKFLOW, job: "validate", scope: "checks" },
]);
expect(jobWriteScopes(document)).toEqual(["publish.contents", "publish.id-token"]);
expect(jobWriteScopes(document)).toEqual(["publish.id-token", "release_finalize.contents"]);
});

test("dev-ci.yml has an exact read-scoped workflow default and no write job scope", async () => {
Expand Down Expand Up @@ -125,7 +127,7 @@ describe("workflow permission policy", () => {
expect(violation.message).toContain(CI_WORKFLOW);
expect(violation.message).toContain("check");
expect(violation.message).toContain("jobs.check.permissions.contents");
expect(violation.message).toContain('only job "publish"');
expect(violation.message).toContain('only jobs "release_finalize"');
});

test("detects a ci.yml check job write-all mutation", async () => {
Expand Down
5 changes: 3 additions & 2 deletions scripts/check-workflow-permissions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@ export interface WorkflowInput {
}

export const JOB_WRITE_ALLOWLIST: readonly { workflow: string; job: string; scope: string }[] = [
{ workflow: ".github/workflows/ci.yml", job: "publish", scope: "contents" },
// GitHub Release finalization writes the release; it holds no OIDC token.
{ workflow: ".github/workflows/ci.yml", job: "release_finalize", scope: "contents" },
// npm trusted publishing (OIDC) needs a GitHub identity token to mint a
// short-lived registry credential. It grants nothing in this repository, and
// it is what removes the long-lived NPM_TOKEN from the release path.
Expand All @@ -48,7 +49,7 @@ const EXPECTED_WORKFLOW_DEFAULT = "an explicit least-privilege permissions block
const EXPECTED_SCOPE_VALUE = '"read", "write", or "none"';
const EXPECTED_NON_WRITE_SCOPE = '"read" or "none"';
const EXPECTED_PERMISSION_VALUE = '"read-all" or a permissions mapping';
const JOB_WRITE_ALLOWLIST_NOTE = 'only job "publish" in .github/workflows/ci.yml may hold contents: write or id-token: write';
const JOB_WRITE_ALLOWLIST_NOTE = 'only jobs "release_finalize" (contents: write) and "publish" (id-token: write) in .github/workflows/ci.yml may hold write scopes';

type RecordValue = Record<string, unknown>;

Expand Down
107 changes: 105 additions & 2 deletions scripts/ci-release-publish.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,7 @@ export type ReleasePublishCli =
| { mode: "check-types" }
| { mode: "dry-run" }
| { mode: "prepare-evidence"; evidenceDir: string; releaseChannel: ReleaseChannel }
| { mode: "finalize-evidence"; evidenceDir: string; releaseChannel: ReleaseChannel }
| { mode: "publish-from-evidence"; evidenceDir: string; releaseSerializationKey: string; releaseChannel: ReleaseChannel };

function extractReleaseChannel(argv: readonly string[]): { releaseChannel: ReleaseChannel; remaining: string[] } {
Expand Down Expand Up @@ -180,8 +181,12 @@ export function parseReleasePublishCli(argv: readonly string[]): ReleasePublishC
const { releaseChannel, remaining } = extractReleaseChannel(argumentsForMode);
return { mode: "publish-from-evidence", ...parsePublishEvidenceOptions(remaining), releaseChannel };
}
case "--finalize-evidence": {
const { releaseChannel, remaining } = extractReleaseChannel(argumentsForMode);
return { mode: "finalize-evidence", evidenceDir: parseEvidenceDirectory(mode, remaining), releaseChannel };
}
default:
throw new Error("Use exactly one mode: --evidence-self-test, --check-types, --dry-run, --prepare-evidence --evidence-dir <directory> [--release-channel stable|nightly], or --publish-from-evidence --evidence-dir <directory> --release-serialization-key <shared-cross-version-key> [--release-channel stable|nightly]");
throw new Error("Use exactly one mode: --evidence-self-test, --check-types, --dry-run, --prepare-evidence --evidence-dir <directory> [--release-channel stable|nightly], --finalize-evidence --evidence-dir <directory> [--release-channel stable|nightly], or --publish-from-evidence --evidence-dir <directory> --release-serialization-key <shared-cross-version-key> [--release-channel stable|nightly]");
}
}
const nativePlatformPackages: readonly PublishPackage[] = [
Expand Down Expand Up @@ -598,6 +603,22 @@ async function prepareExpectedEvidence(evidenceDirectory: string, releaseChannel
});
const expectedPath = path.join(evidenceDirectory, EXPECTED_EVIDENCE_FILE);
const digest = await writeImmutableEvidence(expectedPath, expected);
// The fixed publish boundary no longer runs this script, so the
// pre-publication protected-tag snapshot must be captured here, in the
// credential-free preparation job, for the finalize step's channel
// evidence.
const protectedBefore = await observeRegistryTagSnapshot(expected.packages, NPM_RELEASE_TAG);
await writeImmutableBytes(
path.join(evidenceDirectory, CHANNEL_BEFORE_EVIDENCE_FILE),
canonicalJsonBytes({ schema_version: 1, snapshot: protectedBefore }),
);
// The fixed publish boundary iterates the sealed plan, so dependency order
// (e.g. @gajae-code/ai before @gajae-code/agent-core) must be computed here.
const publicationOrder = planExpectedEvidencePublication(expected.packages).map(record => record.name);
await writeImmutableBytes(
path.join(evidenceDirectory, PUBLISH_ORDER_FILE),
canonicalJsonBytes({ schema_version: 1, order: publicationOrder }),
);
console.log(JSON.stringify({
ok: true,
phase: "expected-evidence",
Expand All @@ -611,6 +632,12 @@ async function prepareExpectedEvidence(evidenceDirectory: string, releaseChannel
function isMissingRegistryPackage(output: string): boolean {
return /\bE404\b|404 Not Found|is not in this registry/iu.test(output);
}
export const CHANNEL_BEFORE_EVIDENCE_FILE = "gajae-release-channel-before-v1.json";
/** Dependency-ordered publication plan sealed by release_prepare; the fixed boundary publishes in exactly this order. */
export const PUBLISH_ORDER_FILE = "gajae-release-publish-order-v1.json";
/** Written by the fixed (no-repo-code) OIDC publish boundary; finalize requires it. */
export const PUBLISH_RECEIPT_FILE = "gajae-release-oidc-publish-receipt-v1.json";

export interface RegistryTagSnapshotRecord {
name: string;
version: string | null;
Expand Down Expand Up @@ -1154,6 +1181,74 @@ async function publishFromExpectedEvidence(evidenceDirectory: string, releaseSer
}));
}

/**
* Finalize a release published by the fixed OIDC boundary: requires the
* boundary's publish receipt, re-observes the registry, and writes the final
* and channel evidence. Runs in the contents:write-only finalize job — never
* in the id-token boundary.
*/
async function finalizeReleaseEvidence(evidenceDirectory: string, releaseChannel: ReleaseChannel): Promise<void> {
const expectedPath = path.join(evidenceDirectory, EXPECTED_EVIDENCE_FILE);
const expectedAsset = await readExpectedEvidenceFile(expectedPath);
assertReleaseVersionForChannel(expectedAsset.value.release_version, releaseChannel);

// The fixed publish boundary must have recorded every expected package.
const receiptPath = path.join(evidenceDirectory, PUBLISH_RECEIPT_FILE);
if (!(await Bun.file(receiptPath).exists())) {
throw new Error(`Missing OIDC publish receipt ${PUBLISH_RECEIPT_FILE}; the fixed publish boundary did not complete`);
}
const receipt = JSON.parse(await Bun.file(receiptPath).text()) as {
schema_version?: number;
release_version?: string;
packages?: { name: string; version: string; tarball_sha512: string }[];
};
if (receipt.schema_version !== 1 || receipt.release_version !== expectedAsset.value.release_version || !Array.isArray(receipt.packages)) {
throw new Error("OIDC publish receipt does not match the expected release identity");
}
const receiptByName = new Map(receipt.packages.map(record => [record.name, record]));
for (const record of expectedAsset.value.packages) {
const seen = receiptByName.get(record.name);
if (seen === undefined || seen.version !== record.version || seen.tarball_sha512 !== record.tarball_sha512) {
throw new Error(`OIDC publish receipt is missing or mismatches ${record.name}@${record.version}`);
}
}

const policy = releasePolicy(releaseChannel);
const observations = await reobserveExpectedEvidencePackages(expectedAsset.value.packages, async (record) => {
const tarballPath = path.join(evidenceDirectory, "tarballs", `${record.tarball_sha512}.tgz`);
return observeRegistryPackage(record, await readRetainedTarball(record, tarballPath), policy);
}, releaseChannel);
const beforePath = path.join(evidenceDirectory, CHANNEL_BEFORE_EVIDENCE_FILE);
const beforeAsset = JSON.parse(await Bun.file(beforePath).text()) as { schema_version?: number; snapshot?: RegistryTagSnapshotRecord[] };
if (beforeAsset.schema_version !== 1 || !Array.isArray(beforeAsset.snapshot)) {
throw new Error(`Missing or malformed pre-publication tag snapshot ${CHANNEL_BEFORE_EVIDENCE_FILE}`);
}
const protectedAfter = await observeRegistryTagSnapshot(expectedAsset.value.packages, NPM_RELEASE_TAG);
const channelEvidence = createReleaseChannelEvidence({
sourceCommit: expectedAsset.value.source_commit,
releaseVersion: expectedAsset.value.release_version,
releaseChannel,
before: beforeAsset.snapshot,
after: protectedAfter,
});
const final = createFinalEvidence(expectedAsset.value, expectedAsset.sha256, observations);
const finalPath = path.join(evidenceDirectory, FINAL_EVIDENCE_FILE);
const finalDigest = await writeImmutableEvidence(finalPath, final);
const channelEvidencePath = path.join(evidenceDirectory, RELEASE_CHANNEL_EVIDENCE_FILE);
const channelEvidenceDigest = await writeImmutableBytes(channelEvidencePath, canonicalJsonBytes(channelEvidence));
console.log(JSON.stringify({
ok: true,
phase: "final-evidence",
expected_evidence: expectedPath,
expected_evidence_sha256: expectedAsset.sha256,
final_evidence: finalPath,
final_evidence_sha256: finalDigest,
verified_packages: final.packages.length,
channel_evidence: channelEvidencePath,
channel_evidence_sha256: channelEvidenceDigest,
}));
}

async function dryRun(): Promise<void> {
isDryRun = true;
try {
Expand Down Expand Up @@ -1187,11 +1282,19 @@ async function main(): Promise<void> {
await dryRun();
return;
}
await checkTypeDeclarations();
if (command.mode === "prepare-evidence") {
// Declaration checks (`bun x tsc`) resolve and execute packages, so they
// belong in the credential-free preparation job. The OIDC publish
// boundary executes no repository code at all, so dependency resolution
// must never re-enter it.
await checkTypeDeclarations();
await prepareExpectedEvidence(command.evidenceDir, command.releaseChannel);
return;
}
if (command.mode === "finalize-evidence") {
await finalizeReleaseEvidence(command.evidenceDir, command.releaseChannel);
return;
}
await publishFromExpectedEvidence(command.evidenceDir, command.releaseSerializationKey, command.releaseChannel);
}

Expand Down
Loading
Loading