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
3 changes: 1 addition & 2 deletions .github/workflows/changelog-merged-proof.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
35 changes: 4 additions & 31 deletions .github/workflows/pylon-preview-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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,
});
Expand Down
38 changes: 8 additions & 30 deletions .github/workflows/pylon-stable-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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") {
Expand Down Expand Up @@ -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) {
Expand Down
2 changes: 1 addition & 1 deletion docs/pylon-publication.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
3 changes: 1 addition & 2 deletions scripts/lib/pylon-publication.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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.");
Expand Down
25 changes: 23 additions & 2 deletions scripts/lib/pylon-workflow-policy.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -141,14 +141,23 @@ 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());
}

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",
Expand All @@ -168,6 +177,13 @@ 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.");
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.");
exactObject(mapping(attest, "permissions"), {
Expand Down Expand Up @@ -351,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.");
Expand Down Expand Up @@ -394,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) {
Expand Down
7 changes: 7 additions & 0 deletions scripts/pylon-prime-supported-release-recipes-v1.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
]
}
Loading
Loading