From f2fc461181d65e43e0d14c0f7152b2cfa88c1478 Mon Sep 17 00:00:00 2001 From: Trevor Walker Date: Tue, 1 Sep 2026 11:56:14 -0600 Subject: [PATCH 1/2] fix(release): repair post-merge publication gates --- .github/workflows/changelog-merged-proof.yml | 3 +- .github/workflows/pylon-preview-release.yml | 35 +-- .github/workflows/pylon-stable-release.yml | 38 +--- docs/pylon-publication.md | 2 +- scripts/lib/pylon-publication.mjs | 3 +- scripts/lib/pylon-workflow-policy.mjs | 11 + ...on-prime-supported-release-recipes-v1.json | 7 + scripts/pylon-publication.test.mjs | 213 +++++++++++++++--- 8 files changed, 215 insertions(+), 97 deletions(-) diff --git a/.github/workflows/changelog-merged-proof.yml b/.github/workflows/changelog-merged-proof.yml index 888b17d7a9..b60ad56d64 100644 --- a/.github/workflows/changelog-merged-proof.yml +++ b/.github/workflows/changelog-merged-proof.yml @@ -65,8 +65,7 @@ jobs: run.status === "completed" && run.conclusion === "success" && run.head_sha === pull.head.sha && run.head_branch === pull.head.ref && run.head_repository?.id === 1349002285 && run.head_repository?.full_name === repository && run.repository?.id === 1349002285 && - run.repository?.full_name === repository && workflow.path === ".github/workflows/changelog-fragment.yml" && - run.pull_requests?.some((candidate) => candidate.number === pull.number) + run.repository?.full_name === repository && workflow.path === ".github/workflows/changelog-fragment.yml" ) { proved = true; break; diff --git a/.github/workflows/pylon-preview-release.yml b/.github/workflows/pylon-preview-release.yml index 00ac153061..3101b852b5 100644 --- a/.github/workflows/pylon-preview-release.yml +++ b/.github/workflows/pylon-preview-release.yml @@ -43,21 +43,6 @@ jobs: core.setFailed("Preview publication event is stale relative to protected pylon."); return; } - const expectedPolicy = [ - { context: "Check changelog fragment", appId: 15368, workflowPath: ".github/workflows/changelog-merged-proof.yml" }, - { context: "build-check-test", appId: 15368, workflowPath: ".github/workflows/ci.yml" }, - ]; - const protection = await github.graphql( - `query($owner:String!,$repo:String!,$ref:String!){repository(owner:$owner,name:$repo){ref(qualifiedName:$ref){branchProtectionRule{requiresStatusChecks requiredStatusChecks{context app{databaseId}}}}}}`, - { ...context.repo, ref: "refs/heads/pylon" }, - ); - const rule = protection.repository?.ref?.branchProtectionRule; - const actualPolicy = Array.isArray(rule?.requiredStatusChecks) ? rule.requiredStatusChecks.map((requirement) => ({ - context: requirement.context, appId: requirement.app?.databaseId ?? null, - })).sort((left, right) => left.context < right.context ? -1 : left.context > right.context ? 1 : 0) : null; - if (!rule?.requiresStatusChecks || JSON.stringify(actualPolicy) !== JSON.stringify(expectedPolicy.map(({ context, appId }) => ({ context, appId })))) { - throw new Error("Protected pylon must require exactly the two app-bound publication checks."); - } // Exact-SHA workflow proof is checked by the final publisher after the push checks can complete. pack: @@ -111,7 +96,7 @@ jobs: - name: Verify and prepare six exact subjects run: | npm run release:pylon:verify - npm run release:pylon:preview -- --publication-policy-revision 1 + npm run release:pylon:preview -- --publication-policy-revision 2 npm run release:pylon:verify-preview - name: Upload isolated preview subjects @@ -296,7 +281,7 @@ jobs: release.source?.commit !== context.sha || release.source?.tree !== preview.build?.source?.tree || release.build?.id !== tag || preview.build?.tag !== tag || preview.build?.releaseManifest?.sha256 !== crypto.createHash("sha256").update(releaseBytes).digest("hex") || - preview.publicationPolicyRevision !== 1 || preview.sequenceEpoch !== 1 || + preview.publicationPolicyRevision !== 2 || preview.sequenceEpoch !== 1 || preview.sequence !== Number(process.env.GITHUB_RUN_NUMBER) || preview.workflowRunId !== process.env.GITHUB_RUN_ID ) throw new Error("Preview tag plan is not bound to the exact source and workflow sequence."); core.setOutput("tag", tag); @@ -484,7 +469,7 @@ jobs: if ( release.source?.commit !== context.sha || release.source?.tree !== preview.build?.source?.tree || release.build?.id !== tag || preview.build?.tag !== tag || preview.build?.releaseManifest?.sha256 !== sha256(releaseBytes) || - preview.publicationPolicyRevision !== 1 || preview.sequenceEpoch !== 1 || + preview.publicationPolicyRevision !== 2 || preview.sequenceEpoch !== 1 || preview.sequence !== Number(process.env.GITHUB_RUN_NUMBER) || preview.workflowRunId !== process.env.GITHUB_RUN_ID ) throw new Error("Preview draft manifests do not bind the exact source and workflow sequence."); const expectedNames = [...release.assets.map((asset) => asset.file), "pylon-prime-agent-release-v1.json", "pylon-preview-channel-v1.json"].sort(); @@ -810,29 +795,17 @@ jobs: previewManifest.build.source.commit !== sourceSha || previewManifest.build.source.tree !== releaseManifest.source.tree || previewManifest.build.releaseManifest.sha256 !== sha256(releaseBytes) || - previewManifest.publicationPolicyRevision !== 1 || previewManifest.sequenceEpoch !== 1 || + previewManifest.publicationPolicyRevision !== 2 || previewManifest.sequenceEpoch !== 1 || previewManifest.sequence !== Number(process.env.GITHUB_RUN_NUMBER) || previewManifest.workflowRunId !== process.env.GITHUB_RUN_ID ) { throw new Error("Downloaded preview metadata is not bound to this exact push and workflow sequence."); } const commit = await github.rest.git.getCommit({ owner, repo, commit_sha: sourceSha }); if (commit.data.tree.sha !== releaseManifest.source.tree) throw new Error("GitHub source tree differs from the build manifest."); - const protection = await github.graphql( - `query($owner:String!,$repo:String!,$ref:String!){repository(owner:$owner,name:$repo){ref(qualifiedName:$ref){branchProtectionRule{requiresStatusChecks requiredStatusChecks{context app{databaseId}}}}}}`, - { owner, repo, ref: "refs/heads/pylon" }, - ); - const rule = protection.repository?.ref?.branchProtectionRule; - const required = rule?.requiredStatusChecks; const expectedPolicy = [ { context: "Check changelog fragment", appId: 15368, workflowPath: ".github/workflows/changelog-merged-proof.yml" }, { context: "build-check-test", appId: 15368, workflowPath: ".github/workflows/ci.yml" }, ]; - const actualPolicy = Array.isArray(required) ? required.map((requirement) => ({ - context: requirement.context, appId: requirement.app?.databaseId ?? null, - })).sort((left, right) => left.context < right.context ? -1 : left.context > right.context ? 1 : 0) : null; - if (!rule?.requiresStatusChecks || JSON.stringify(actualPolicy) !== JSON.stringify(expectedPolicy.map(({ context, appId }) => ({ context, appId })))) { - throw new Error("Protected pylon must require exactly the two app-bound publication checks."); - } const checks = await github.paginate(github.rest.checks.listForRef, { owner, repo, ref: sourceSha, filter: "latest", per_page: 100, }); diff --git a/.github/workflows/pylon-stable-release.yml b/.github/workflows/pylon-stable-release.yml index 07385129da..24c7ecc78e 100644 --- a/.github/workflows/pylon-stable-release.yml +++ b/.github/workflows/pylon-stable-release.yml @@ -240,22 +240,10 @@ jobs: const sourceCommit = await github.rest.git.getCommit({ owner, repo, commit_sha: sourceSha }); sourceTree = sourceCommit.data.tree.sha; } - const protection = await github.graphql( - `query($owner:String!,$repo:String!,$ref:String!){repository(owner:$owner,name:$repo){ref(qualifiedName:$ref){branchProtectionRule{requiresStatusChecks requiredStatusChecks{context app{databaseId}}}}}}`, - { owner, repo, ref: "refs/heads/pylon" }, - ); - const rule = protection.repository?.ref?.branchProtectionRule; - const required = rule?.requiredStatusChecks; const expectedPolicy = [ { context: "Check changelog fragment", appId: 15368, workflowPath: ".github/workflows/changelog-merged-proof.yml" }, { context: "build-check-test", appId: 15368, workflowPath: ".github/workflows/ci.yml" }, ]; - const actualPolicy = Array.isArray(required) ? required.map((requirement) => ({ - context: requirement.context, appId: requirement.app?.databaseId ?? null, - })).sort((left, right) => left.context < right.context ? -1 : left.context > right.context ? 1 : 0) : null; - if (!rule?.requiresStatusChecks || JSON.stringify(actualPolicy) !== JSON.stringify(expectedPolicy.map(({ context, appId }) => ({ context, appId })))) { - throw new Error("Protected pylon must require exactly the two app-bound publication checks."); - } const proveChecks = async (sha, label) => { const checks = await github.paginate(github.rest.checks.listForRef, { owner, repo, ref: sha, filter: "latest", per_page: 100 }); for (const requirement of expectedPolicy) { @@ -485,7 +473,7 @@ jobs: --operation "$OPERATION" --policy-sha "${{ github.sha }}" --policy-tree "$policy_tree" - --publication-policy-revision 1 + --publication-policy-revision 2 ) if [ "$OPERATION" = withdraw ]; then args+=(--revoke-tag "$REVOKE_STABLE_TAG" --reason "$REASON") @@ -569,7 +557,7 @@ jobs: if ( manifest.schemaVersion !== 1 || manifest.channel !== "stable" || manifest.repository !== "https://github.com/pylon-code/prime-agent" || - manifest.promotion?.publicationPolicyRevision !== 1 || + manifest.promotion?.publicationPolicyRevision !== 2 || !/^pylon-stable-[0-9]{6}-g[0-9a-f]{12}-r[1-9][0-9]*$/.test(manifest.tag) ) throw new Error("Stable manifest identity is malformed."); NODE @@ -675,8 +663,8 @@ jobs: if ( !parsed || !preview || Number(parsed[1]) !== manifest.sequence || parsed[2] !== manifest.build.source?.commit?.slice(0, 12) || Number(parsed[3]) !== manifest.build.recipeRevision || preview[1] !== parsed[2] || Number(preview[2]) !== manifest.build.recipeRevision || - manifest.build.previewTag !== manifest.build.id || ![1].includes(manifest.build.publicationPolicyRevision) || - manifest.promotion?.policyCommit !== context.sha || manifest.promotion?.publicationPolicyRevision !== 1 + manifest.build.previewTag !== manifest.build.id || ![1, 2].includes(manifest.build.publicationPolicyRevision) || + manifest.promotion?.policyCommit !== context.sha || manifest.promotion?.publicationPolicyRevision !== 2 ) throw new Error("Stable draft identity is malformed or not signed by this policy commit."); const name = `Pylon Prime stable ${tag}`; const encoded = bytes.toString("base64"); @@ -852,8 +840,10 @@ jobs: Number(previewMatch[2]) !== manifest.build?.recipeRevision || manifest.build.previewTag !== manifest.build.id || manifest.build.previewSequence?.sequenceEpoch !== 1 || !Number.isSafeInteger(manifest.build.previewSequence?.sequence) || manifest.build.previewSequence.sequence < 1 || !/^[1-9][0-9]*$/.test(manifest.build.previewSequence?.workflowRunId ?? "") || - manifest.build.previewTag !== process.env.PREVIEW_TAG || ![1].includes(manifest.build.publicationPolicyRevision) || - manifest.promotion?.kind !== operation || manifest.promotion?.publicationPolicyRevision !== 1 || + manifest.build.previewTag !== process.env.PREVIEW_TAG || ![1, 2].includes(manifest.build.publicationPolicyRevision) || + manifest.promotion?.kind !== operation || + (mode === "normal" ? manifest.promotion?.publicationPolicyRevision !== 2 : + ![1, 2].includes(manifest.promotion?.publicationPolicyRevision)) || manifest.promotion?.policyCommit !== process.env.POLICY_SHA || manifest.promotion?.policyTree !== process.env.POLICY_TREE ) throw new Error("Stable manifest, preview recipe, operator request, or policy identity differs."); if (operation === "withdraw") { @@ -917,22 +907,10 @@ jobs: const previewRef = await github.rest.git.getRef({ owner, repo, ref: `tags/${manifest.build.previewTag}` }); if (previewRef.data.object.type !== "commit" || previewRef.data.object.sha !== manifest.build.source.commit) throw new Error("Immutable preview tag changed before stable CAS."); - const protection = await github.graphql( - `query($owner:String!,$repo:String!,$ref:String!){repository(owner:$owner,name:$repo){ref(qualifiedName:$ref){branchProtectionRule{requiresStatusChecks requiredStatusChecks{context app{databaseId}}}}}}`, - { owner, repo, ref: "refs/heads/pylon" }, - ); - const rule = protection.repository?.ref?.branchProtectionRule; - const required = rule?.requiredStatusChecks; const expectedPolicy = [ { context: "Check changelog fragment", appId: 15368, workflowPath: ".github/workflows/changelog-merged-proof.yml" }, { context: "build-check-test", appId: 15368, workflowPath: ".github/workflows/ci.yml" }, ]; - const actualPolicy = Array.isArray(required) ? required.map((requirement) => ({ - context: requirement.context, appId: requirement.app?.databaseId ?? null, - })).sort((left, right) => left.context < right.context ? -1 : left.context > right.context ? 1 : 0) : null; - if (!rule?.requiresStatusChecks || JSON.stringify(actualPolicy) !== JSON.stringify(expectedPolicy.map(({ context, appId }) => ({ context, appId })))) { - throw new Error("Protected pylon must require exactly the two app-bound publication checks."); - } const proveChecks = async (sha, label) => { const checks = await github.paginate(github.rest.checks.listForRef, { owner, repo, ref: sha, filter: "latest", per_page: 100 }); for (const requirement of expectedPolicy) { diff --git a/docs/pylon-publication.md b/docs/pylon-publication.md index 93efd86452..1a8ca27fb5 100644 --- a/docs/pylon-publication.md +++ b/docs/pylon-publication.md @@ -86,7 +86,7 @@ The canonical preview manifest binds the full source commit/tree, artifact recip ```json { - "publicationPolicyRevision": 1, + "publicationPolicyRevision": 2, "sequenceEpoch": 1, "sequence": 123, "workflowRunId": "33428882721" diff --git a/scripts/lib/pylon-publication.mjs b/scripts/lib/pylon-publication.mjs index 8649806907..30051c25d4 100644 --- a/scripts/lib/pylon-publication.mjs +++ b/scripts/lib/pylon-publication.mjs @@ -664,8 +664,7 @@ export function validateMergedChangelogProof({ repository, ref, eventName, merge run.head_sha === pullRequest.head.sha && run.path === ".github/workflows/changelog-fragment.yml" && run.repository === PYLON_PUBLICATION_REPOSITORY && - run.headRepository === PYLON_PUBLICATION_REPOSITORY && - run.pullRequests?.includes(pullRequest.number) + run.headRepository === PYLON_PUBLICATION_REPOSITORY ); }); if (valid.length < 1) throw new Error("Merged PR has no successful GitHub Actions changelog head check with exact provenance."); diff --git a/scripts/lib/pylon-workflow-policy.mjs b/scripts/lib/pylon-workflow-policy.mjs index 25dd6e9151..b4ab3f36eb 100644 --- a/scripts/lib/pylon-workflow-policy.mjs +++ b/scripts/lib/pylon-workflow-policy.mjs @@ -141,6 +141,12 @@ function assertNoDownloadedOrRepositoryExecution(block, description) { ) throw new Error(`${description} may not checkout or execute repository/downloaded code.`); } +function assertNoBranchProtectionAdministrationRead(block, description) { + if ( + /branchProtectionRule|requiredStatusChecks|github\.rest\.repos\.(?:getBranchProtection|getAdminBranchProtection)|\/branches\/[^\s"'`]+\/protection/.test(block) + ) throw new Error(`${description} may not make an Administration-gated branch-protection read.`); +} + function canonicalList(values) { return JSON.stringify([...values].sort()); } @@ -168,6 +174,11 @@ export function validateApprovedAttestationWorkflow(workflow, channel) { : null; if (!policy) throw new Error("Unknown approved attestation channel."); if (!/^permissions:\s*\{\}\s*$/m.test(workflow)) throw new Error("Approved publication workflow needs deny-by-default permissions."); + assertNoBranchProtectionAdministrationRead(workflow, "Default-token publication workflow"); + const admission = jobBlock(workflow, "admission"); + if (/^ environment:|PYLON_RULESET_AUDITOR|permission-administration|github-token:/m.test(admission)) { + throw new Error("Source admission may not receive the protected ruleset-auditor environment or credential."); + } const attest = jobBlock(workflow, "attest"); if (scalar(attest, "environment") !== policy.environment) throw new Error("Attester lacks its exact approval environment."); exactObject(mapping(attest, "permissions"), { diff --git a/scripts/pylon-prime-supported-release-recipes-v1.json b/scripts/pylon-prime-supported-release-recipes-v1.json index 8d73871e6e..3d447aeb69 100644 --- a/scripts/pylon-prime-supported-release-recipes-v1.json +++ b/scripts/pylon-prime-supported-release-recipes-v1.json @@ -16,6 +16,13 @@ "previewWorkflowSha256": "e790a5da7063bd40fbd886e84945c3200291194fdbd5b002079349e45356a41d", "stableWorkflowPath": ".github/workflows/pylon-stable-release.yml", "stableWorkflowSha256": "dfcecdf6b58f143f9b7a543eadd124c190350ae29ac9eadccb907f1398b0958a" + }, + { + "publicationPolicyRevision": 2, + "previewWorkflowPath": ".github/workflows/pylon-preview-release.yml", + "previewWorkflowSha256": "9f4e3f38fb0bdb9c11662310c5369fb792765a3090e0f74b0ec0b34127b43ed8", + "stableWorkflowPath": ".github/workflows/pylon-stable-release.yml", + "stableWorkflowSha256": "0f04d1f55f54312d933087d88de6883e8408bb0cd9f060d3b5851d710698b1af" } ] } diff --git a/scripts/pylon-publication.test.mjs b/scripts/pylon-publication.test.mjs index 416a376a57..637b7c9c2f 100644 --- a/scripts/pylon-publication.test.mjs +++ b/scripts/pylon-publication.test.mjs @@ -366,7 +366,7 @@ test("preview manifest binds the full source tree, build, recipe, and build-mani ]) assert.throws(() => createPreviewManifest(release, releaseBytes, invalid), /sequence identity/); for (const invalid of [ { ...invocation, publicationPolicyRevision: 0 }, - { ...invocation, publicationPolicyRevision: 2 }, + { ...invocation, publicationPolicyRevision: 3 }, { sequenceEpoch: 1, sequence: 17, workflowRunId: "33428882721" }, ]) assert.throws(() => createPreviewManifest(release, releaseBytes, invalid), /policy revision/); for (const mutate of [ @@ -769,7 +769,7 @@ test("stable manifest nested schema rejects extras, malformed identities, unsafe (value) => (value.build.source.tree = "abc"), (value) => (value.build.recipeRevision = 2), (value) => delete value.build.publicationPolicyRevision, - (value) => (value.build.publicationPolicyRevision = 2), + (value) => (value.build.publicationPolicyRevision = 3), (value) => (value.build.releaseManifest.file = "other.json"), (value) => (value.build.previewManifest.file = "other.json"), (value) => (value.build.assets[0].file = "../escape.tgz"), @@ -779,7 +779,7 @@ test("stable manifest nested schema rejects extras, malformed identities, unsafe (value) => value.build.assets.reverse(), (value) => (value.promotion.policyTree = "abc"), (value) => delete value.promotion.publicationPolicyRevision, - (value) => (value.promotion.publicationPolicyRevision = 2), + (value) => (value.promotion.publicationPolicyRevision = 3), ]) { const changed = structuredClone(stable); mutate(changed); @@ -859,7 +859,7 @@ test("merged changelog proof never relabels a PR-head check as merge-SHA evidenc path: ".github/workflows/changelog-fragment.yml", repository: PYLON_PUBLICATION_REPOSITORY, headRepository: PYLON_PUBLICATION_REPOSITORY, - pullRequests: [29], + pullRequests: [], }], }; assert.equal(validateMergedChangelogProof(proof).number, 29); @@ -879,6 +879,136 @@ test("merged changelog proof never relabels a PR-head check as merge-SHA evidenc } }); +test("merged changelog workflow accepts the live deleted-head response with an empty run pull_requests array", async () => { + const mergeSha = "c34a2dd3b393700d512b0cbb30eb0bf66bd6dcc8"; + const headSha = "d".repeat(40); + const pullNumber = 32; + const headRef = "fix/protected-pylon-publication"; + const checkSuiteId = 771; + const checkRunId = 772; + const workflowRunId = 33_539_105_742; + const workflowId = 773; + const listAssociated = async () => {}; + const listChecks = async () => {}; + const script = githubScriptForStep( + ".github/workflows/changelog-merged-proof.yml", + "Prove the merged pull request head check", + ); + for (const binding of [ + "pull.merge_commit_sha === mergeSha", + 'pull.base.ref === "pylon"', + "pull.head.repo?.full_name !== repository", + "check.head_sha === pull.head.sha", + "check.app?.id === 15368", + "run.check_suite_id === suite.id", + 'run.event === "pull_request"', + "run.head_sha === pull.head.sha", + "run.head_branch === pull.head.ref", + 'workflow.path === ".github/workflows/changelog-fragment.yml"', + ]) assert.ok(script.includes(binding), `merged changelog workflow lost direct binding: ${binding}`); + assert.doesNotMatch(script, /run\.pull_requests/, "Actions run pull_requests is not stable after head deletion"); + + let deletedHeadLookups = 0; + const github = { + paginate: async (method, parameters) => { + if (method === listAssociated) { + assert.deepEqual(parameters, { + owner: "pylon-code", repo: "prime-agent", commit_sha: mergeSha, per_page: 100, + }); + return [{ number: pullNumber }]; + } + if (method === listChecks) { + assert.deepEqual(parameters, { + owner: "pylon-code", repo: "prime-agent", ref: headSha, filter: "latest", per_page: 100, + }); + return [{ + id: checkRunId, + name: "Check changelog fragment", + head_sha: headSha, + app: { id: GITHUB_ACTIONS_APP_ID }, + status: "completed", + conclusion: "success", + check_suite: { id: checkSuiteId }, + details_url: `https://github.com/pylon-code/prime-agent/actions/runs/${workflowRunId}/job/774`, + }]; + } + throw new Error("Unexpected pagination endpoint."); + }, + rest: { + repos: { + listPullRequestsAssociatedWithCommit: listAssociated, + }, + pulls: { + get: async (parameters) => { + assert.deepEqual(parameters, { owner: "pylon-code", repo: "prime-agent", pull_number: pullNumber }); + return { data: { + number: pullNumber, + merged_at: "2026-09-01T00:00:00Z", + merge_commit_sha: mergeSha, + base: { ref: "pylon", repo: { full_name: PYLON_PUBLICATION_REPOSITORY } }, + head: { ref: headRef, sha: headSha, repo: { full_name: PYLON_PUBLICATION_REPOSITORY } }, + } }; + }, + }, + checks: { + listForRef: listChecks, + getSuite: async (parameters) => { + assert.deepEqual(parameters, { + owner: "pylon-code", repo: "prime-agent", check_suite_id: checkSuiteId, + }); + return { data: { + id: checkSuiteId, + app: { id: GITHUB_ACTIONS_APP_ID }, + head_sha: headSha, + status: "completed", + conclusion: "success", + } }; + }, + }, + actions: { + getWorkflowRun: async (parameters) => { + assert.deepEqual(parameters, { + owner: "pylon-code", repo: "prime-agent", run_id: workflowRunId, + }); + return { data: { + id: workflowRunId, + workflow_id: workflowId, + check_suite_id: checkSuiteId, + event: "pull_request", + status: "completed", + conclusion: "success", + head_sha: headSha, + head_branch: headRef, + head_repository: { id: 1_349_002_285, full_name: PYLON_PUBLICATION_REPOSITORY }, + repository: { id: 1_349_002_285, full_name: PYLON_PUBLICATION_REPOSITORY }, + pull_requests: [], + } }; + }, + getWorkflow: async (parameters) => { + assert.deepEqual(parameters, { + owner: "pylon-code", repo: "prime-agent", workflow_id: workflowId, + }); + return { data: { path: ".github/workflows/changelog-fragment.yml" } }; + }, + }, + git: { + getRef: async () => { + deletedHeadLookups += 1; + throw Object.assign(new Error("Reference does not exist"), { status: 404 }); + }, + }, + }, + }; + const execute = new AsyncFunction("github", "context", "core", script); + await execute(github, { + repo: { owner: "pylon-code", repo: "prime-agent" }, + eventName: "push", + ref: PYLON_PUBLICATION_REF, + sha: mergeSha, + }, {}); + assert.equal(deletedHeadLookups, 0, "proof must stay SHA-bound when the merged head branch no longer resolves"); +}); + test("canonical invocation rejects main, tags, PR events, forks, and malformed source", () => { const good = { repository: PYLON_PUBLICATION_REPOSITORY, @@ -1096,7 +1226,12 @@ test("standalone preview verification rejects tamper, extras, symlinks, and nonc test("recipe and publication policy registries close independent immutable identities", () => { const registryText = readFileSync(join(root, "scripts/pylon-prime-supported-release-recipes-v1.json"), "utf8"); const registry = parseSupportedReleaseRecipeRegistry(registryText); - const policy = registry.publicationPolicies[0]; + const policies = registry.publicationPolicies; + const historicalPolicy = policies[0]; + const policy = policies.at(-1); + assert.deepEqual(policies.map((candidate) => candidate.publicationPolicyRevision), [1, 2]); + assert.equal(historicalPolicy.previewWorkflowSha256, "e790a5da7063bd40fbd886e84945c3200291194fdbd5b002079349e45356a41d"); + assert.equal(historicalPolicy.stableWorkflowSha256, "dfcecdf6b58f143f9b7a543eadd124c190350ae29ac9eadccb907f1398b0958a"); const preview = readFileSync(join(root, policy.previewWorkflowPath), "utf8"); const stable = readFileSync(join(root, policy.stableWorkflowPath), "utf8"); assert.deepEqual(validateApprovedWorkflowBytes( @@ -1123,28 +1258,27 @@ test("recipe and publication policy registries close independent immutable ident policy.publicationPolicyRevision, ), /bytes differ/); assert.throws(() => validateApprovedWorkflowBytes(policy.previewWorkflowPath, preview, "preview", 999), /Unsupported/); + assert.throws( + () => validateApprovedAttestationWorkflow(preview.replace( + "// Exact-SHA workflow proof is checked by the final publisher after the push checks can complete.", + "// branchProtectionRule requiredStatusChecks", + ), "preview"), + /Administration-gated branch-protection read/, + ); - const stableR2 = stable - .replace("--publication-policy-revision 1", "--publication-policy-revision 2") - .replaceAll("promotion?.publicationPolicyRevision !== 1", "promotion?.publicationPolicyRevision !== 2"); - assert.notEqual(stableR2, stable); - assert.match(stableR2, /--publication-policy-revision 2/); - assert.match(stableR2, /promotion\?\.publicationPolicyRevision !== 2/); - assert.match(stableR2, /!\[1\]\.includes\(manifest\.build\.publicationPolicyRevision\)/); - const policyR2 = { - ...policy, - publicationPolicyRevision: 2, - stableWorkflowSha256: sha256Bytes(Buffer.from(stableR2)), - }; - const policies = [policy, policyR2]; - assert.deepEqual(validateApprovedWorkflowBytes( - policyR2.stableWorkflowPath, - stableR2, - "stable", - 2, - policies, - ), { workflow: policyR2.stableWorkflowPath, environment: "pylon-stable" }); - assert.throws(() => validateApprovedWorkflowBytes(policy.stableWorkflowPath, stableR2, "stable", 1, policies), /bytes differ/); + assert.match(preview, /--publication-policy-revision 2/); + assert.match(preview, /preview(?:Manifest)?\.publicationPolicyRevision !== 2/); + assert.match(stable, /--publication-policy-revision 2/); + assert.match(stable, /promotion\?\.publicationPolicyRevision !== 2/); + assert.match(stable, /!\[1, 2\]\.includes\(manifest\.build\.publicationPolicyRevision\)/); + assert.throws( + () => validateApprovedWorkflowBytes(historicalPolicy.previewWorkflowPath, preview, "preview", 1, policies), + /bytes differ/, + ); + assert.throws( + () => validateApprovedWorkflowBytes(historicalPolicy.stableWorkflowPath, stable, "stable", 1, policies), + /bytes differ/, + ); const promotedByR2 = firstStable(); promotedByR2.promotion.publicationPolicyRevision = 2; assert.equal(validateStableManifest(promotedByR2, registry.recipes, policies), promotedByR2); @@ -2541,9 +2675,23 @@ test("consumer state locking, recovery, transaction fencing, durability, and pat } }); -test("admission is non-authoritative and every protected mutation has a fresh combined App audit", async () => { +test("default-token source admission avoids Administration reads and every protected mutation has a fresh App audit", async () => { for (const [workflow, step] of [ [".github/workflows/pylon-preview-release.yml", "Require the canonical protected push"], + [".github/workflows/pylon-stable-release.yml", "Require protected pylon and an exact verified preview source"], + ]) { + const script = githubScriptForStep(workflow, step); + for (const value of ["pylon-code", "prime-agent", "refs/heads/pylon", "context.sha", "heads/pylon"]) { + assert.ok(script.includes(value), `${workflow}:${step} lacks canonical source binding ${value}`); + } + assert.doesNotMatch( + script, + /branchProtectionRule|requiredStatusChecks|getBranchProtection|\/branches\/[^\s"'`]+\/protection|github\.graphql|rulesets\/\{ruleset_id\}/, + "default GITHUB_TOKEN source admission must not request Administration or branch-protection data", + ); + } + + for (const [workflow, step] of [ [".github/workflows/pylon-preview-release.yml", "Verify exact checks and freeze the approved preview draft"], [".github/workflows/pylon-stable-release.yml", "Require protected pylon and an exact verified preview source"], [".github/workflows/pylon-stable-release.yml", "Re-download and validate the exact stable transaction"], @@ -2552,10 +2700,12 @@ test("admission is non-authoritative and every protected mutation has a fresh co for (const value of [ "Check changelog fragment", "build-check-test", "15368", ".github/workflows/changelog-merged-proof.yml", ".github/workflows/ci.yml", - ]) assert.ok(script.includes(value), `${workflow}:${step} lacks ${value}`); - assert.match(script, /JSON\.stringify\(actualPolicy\) !== JSON\.stringify/); - assert.doesNotMatch(script, /rulesets\/\{ruleset_id\}|bypass_actors|current_user_can_bypass/, - "normal GITHUB_TOKEN admission must not claim authoritative ruleset visibility"); + "run.check_suite_id === suite.id", 'run.event === "push"', + ]) assert.ok(script.includes(value), `${workflow}:${step} lacks direct exact-check binding ${value}`); + assert.match(script, /check\.head_sha === (?:sha|sourceSha)/); + assert.match(script, /run\.head_sha === (?:sha|sourceSha)/); + assert.doesNotMatch(script, /branchProtectionRule|requiredStatusChecks|getBranchProtection|github\.graphql/, + "default-token exact-check proof must not depend on Administration data"); } const authoritativeSteps = [ @@ -2850,6 +3000,7 @@ test("stable stage survives a crash after createRelease by recovering its exact const oldManifest = process.env.STABLE_MANIFEST; try { const manifest = firstStable(); + manifest.promotion.publicationPolicyRevision = 2; const bytes = Buffer.from(canonicalJson(manifest)); const manifestPath = join(fixture, PYLON_STABLE_MANIFEST); writeFileSync(manifestPath, bytes); From c922418230c9463d012b16bb1265da3172d1b65f Mon Sep 17 00:00:00 2001 From: Trevor Walker Date: Tue, 1 Sep 2026 12:12:21 -0600 Subject: [PATCH 2/2] fix(release): preserve historical publication policy --- scripts/lib/pylon-workflow-policy.mjs | 22 ++++++--- scripts/pylon-publication.test.mjs | 67 ++++++++++++++++++++++----- 2 files changed, 72 insertions(+), 17 deletions(-) diff --git a/scripts/lib/pylon-workflow-policy.mjs b/scripts/lib/pylon-workflow-policy.mjs index b4ab3f36eb..a16ed0effd 100644 --- a/scripts/lib/pylon-workflow-policy.mjs +++ b/scripts/lib/pylon-workflow-policy.mjs @@ -151,10 +151,13 @@ function canonicalList(values) { return JSON.stringify([...values].sort()); } -export function validateApprovedAttestationWorkflow(workflow, channel) { +function validateApprovedAttestationWorkflowForRevision(workflow, channel, publicationPolicyRevision) { if (typeof workflow !== "string" || !workflow.endsWith("\n") || workflow.includes("\r")) { throw new Error("Approved workflow bytes must be normalized text."); } + if (!Number.isSafeInteger(publicationPolicyRevision) || publicationPolicyRevision < 1) { + throw new Error("Workflow semantics need an exact positive publication policy revision."); + } const policy = channel === "preview" ? { environment: "pylon-preview", @@ -174,10 +177,12 @@ export function validateApprovedAttestationWorkflow(workflow, channel) { : null; if (!policy) throw new Error("Unknown approved attestation channel."); if (!/^permissions:\s*\{\}\s*$/m.test(workflow)) throw new Error("Approved publication workflow needs deny-by-default permissions."); - assertNoBranchProtectionAdministrationRead(workflow, "Default-token publication workflow"); - const admission = jobBlock(workflow, "admission"); - if (/^ environment:|PYLON_RULESET_AUDITOR|permission-administration|github-token:/m.test(admission)) { - throw new Error("Source admission may not receive the protected ruleset-auditor environment or credential."); + if (publicationPolicyRevision >= 2) { + assertNoBranchProtectionAdministrationRead(workflow, "Default-token publication workflow"); + const admission = jobBlock(workflow, "admission"); + if (/^ environment:|PYLON_RULESET_AUDITOR|permission-administration|github-token:/m.test(admission)) { + throw new Error("Source admission may not receive the protected ruleset-auditor environment or credential."); + } } const attest = jobBlock(workflow, "attest"); if (scalar(attest, "environment") !== policy.environment) throw new Error("Attester lacks its exact approval environment."); @@ -362,6 +367,11 @@ export function validateApprovedAttestationWorkflow(workflow, channel) { return { workflow: policy.workflow, environment: policy.environment }; } +// Historical semantics are reachable only after validateApprovedWorkflowBytes verifies the registered hash. +export function validateApprovedAttestationWorkflow(workflow, channel) { + return validateApprovedAttestationWorkflowForRevision(workflow, channel, 2); +} + export function readWorkflowAtSignerDigest(workflowPath, signerDigest) { if (![PYLON_PREVIEW_WORKFLOW, PYLON_STABLE_WORKFLOW].includes(workflowPath)) throw new Error("Unsupported attestation workflow path."); if (!/^[0-9a-f]{40}$/.test(signerDigest)) throw new Error("Workflow signer digest must be a full lowercase Git SHA."); @@ -405,7 +415,7 @@ export function validateApprovedWorkflowBytes( if (actualDigest !== expectedDigest) { throw new Error(`Signer workflow bytes differ from publication policy p${publicationPolicyRevision} for ${channel}.`); } - return validateApprovedAttestationWorkflow(workflowText, channel); + return validateApprovedAttestationWorkflowForRevision(workflowText, channel, publicationPolicyRevision); } export function verifyApprovedWorkflowAtSignerDigest(workflowPath, signerDigest, channel, publicationPolicyRevision) { diff --git a/scripts/pylon-publication.test.mjs b/scripts/pylon-publication.test.mjs index 637b7c9c2f..9b340a3277 100644 --- a/scripts/pylon-publication.test.mjs +++ b/scripts/pylon-publication.test.mjs @@ -879,15 +879,15 @@ test("merged changelog proof never relabels a PR-head check as merge-SHA evidenc } }); -test("merged changelog workflow accepts the live deleted-head response with an empty run pull_requests array", async () => { +test("merged changelog workflow accepts the actual deleted PR-head run response with an empty pull_requests array", async () => { const mergeSha = "c34a2dd3b393700d512b0cbb30eb0bf66bd6dcc8"; - const headSha = "d".repeat(40); - const pullNumber = 32; - const headRef = "fix/protected-pylon-publication"; - const checkSuiteId = 771; - const checkRunId = 772; - const workflowRunId = 33_539_105_742; - const workflowId = 773; + const headSha = "f4d9ef03b529faf2e07031c8b7cd703363316ae5"; + const pullNumber = 42; + const headRef = "feat/pylon-protected-publication"; + const checkSuiteId = 90_698_064_557; + const checkRunId = 99_743_581_069; + const workflowRunId = 33_472_036_328; + const workflowId = 344_179_420; const listAssociated = async () => {}; const listChecks = async () => {}; const script = githubScriptForStep( @@ -929,7 +929,7 @@ test("merged changelog workflow accepts the live deleted-head response with an e status: "completed", conclusion: "success", check_suite: { id: checkSuiteId }, - details_url: `https://github.com/pylon-code/prime-agent/actions/runs/${workflowRunId}/job/774`, + details_url: `https://github.com/pylon-code/prime-agent/actions/runs/${workflowRunId}/job/${checkRunId}`, }]; } throw new Error("Unexpected pagination endpoint."); @@ -943,7 +943,7 @@ test("merged changelog workflow accepts the live deleted-head response with an e assert.deepEqual(parameters, { owner: "pylon-code", repo: "prime-agent", pull_number: pullNumber }); return { data: { number: pullNumber, - merged_at: "2026-09-01T00:00:00Z", + merged_at: "2026-09-01T17:39:57Z", merge_commit_sha: mergeSha, base: { ref: "pylon", repo: { full_name: PYLON_PUBLICATION_REPOSITORY } }, head: { ref: headRef, sha: headSha, repo: { full_name: PYLON_PUBLICATION_REPOSITORY } }, @@ -1246,6 +1246,50 @@ test("recipe and publication policy registries close independent immutable ident "stable", policy.publicationPolicyRevision, ), { workflow: policy.stableWorkflowPath, environment: "pylon-stable" }); + const retiredAdministrationRead = "branchProtectionRule requiredStatusChecks"; + const legacyPreviewShape = preview.replace( + "// Exact-SHA workflow proof is checked by the final publisher after the push checks can complete.", + `// ${retiredAdministrationRead}`, + ); + const legacyStableShape = stable.replace( + ' const requestedPreview = process.env.PREVIEW_TAG;\n', + ` // ${retiredAdministrationRead}\n const requestedPreview = process.env.PREVIEW_TAG;\n`, + ); + const legacyShapePolicy = { + ...historicalPolicy, + previewWorkflowSha256: createHash("sha256").update(legacyPreviewShape).digest("hex"), + stableWorkflowSha256: createHash("sha256").update(legacyStableShape).digest("hex"), + }; + for (const [workflowPath, workflow, channel, environment] of [ + [legacyShapePolicy.previewWorkflowPath, legacyPreviewShape, "preview", "pylon-preview"], + [legacyShapePolicy.stableWorkflowPath, legacyStableShape, "stable", "pylon-stable"], + ]) { + assert.match(workflow, /branchProtectionRule requiredStatusChecks/); + assert.deepEqual( + validateApprovedWorkflowBytes(workflowPath, workflow, channel, 1, [legacyShapePolicy]), + { workflow: workflowPath, environment }, + ); + assert.throws( + () => validateApprovedWorkflowBytes(workflowPath, workflow, channel, 1, policies), + /bytes differ/, + "p1 revision dispatch must remain behind the exact registered workflow hash", + ); + const revision2ShapePolicy = { ...legacyShapePolicy, publicationPolicyRevision: 2 }; + assert.throws( + () => validateApprovedWorkflowBytes(workflowPath, workflow, channel, 2, [revision2ShapePolicy]), + /Administration-gated branch-protection read/, + ); + } + assert.throws( + () => validateApprovedWorkflowBytes( + legacyShapePolicy.previewWorkflowPath, + `${legacyPreviewShape}# arbitrary p1 bytes\n`, + "preview", + 1, + [legacyShapePolicy], + ), + /bytes differ/, + ); for (const changed of [ `${preview}\n rogue:\n permissions: write-all\n runs-on: ubuntu-latest\n steps:\n - run: echo arbitrary\n`, `${stable}\n rogue-oidc:\n permissions:\n id-token: write\n attestations: write\n runs-on: ubuntu-latest\n steps:\n - run: echo sign\n`, @@ -2909,7 +2953,7 @@ test("stable recovery body durably carries bounded exact canonical manifest byte }), /49152/); }); -test("stable zero-asset recovery reuses the body-carried attested bytes and excludes only its draft", async () => { +test("stable p1 zero-asset recovery carries the historical policy revision to attestation", async () => { const fixture = mkdtempSync(join(tmpdir(), "pylon-stable-recovery-")); try { const manifest = firstStable(); @@ -2938,6 +2982,7 @@ test("stable zero-asset recovery reuses the body-carried attested bytes and excl }, verifyAttestation: (path, policyCommit, policyTree) => { assert.equal(readFileSync(path).equals(bytes), true); + assert.equal(JSON.parse(readFileSync(path, "utf8")).promotion.publicationPolicyRevision, 1); assert.equal(policyCommit, source.commit); assert.equal(policyTree, source.tree); verified = true;