diff --git a/.github/workflows/pylon-preview-release.yml b/.github/workflows/pylon-preview-release.yml index 3101b852b5a..02f8ca24dc4 100644 --- a/.github/workflows/pylon-preview-release.yml +++ b/.github/workflows/pylon-preview-release.yml @@ -96,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 2 + npm run release:pylon:preview -- --publication-policy-revision 3 npm run release:pylon:verify-preview - name: Upload isolated preview subjects @@ -281,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 !== 2 || preview.sequenceEpoch !== 1 || + preview.publicationPolicyRevision !== 3 || 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); @@ -469,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 !== 2 || preview.sequenceEpoch !== 1 || + preview.publicationPolicyRevision !== 3 || 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(); @@ -552,7 +552,7 @@ jobs: await requireLivePylon(); } for (const asset of missing) { - await github.request("POST /repos/{owner}/{repo}/releases/{release_id}/assets", { + await github.rest.repos.uploadReleaseAsset({ owner, repo, release_id: draft.id, name: asset.name, data: asset.bytes, headers: { "content-type": "application/octet-stream", "content-length": asset.size }, }); @@ -795,7 +795,7 @@ jobs: previewManifest.build.source.commit !== sourceSha || previewManifest.build.source.tree !== releaseManifest.source.tree || previewManifest.build.releaseManifest.sha256 !== sha256(releaseBytes) || - previewManifest.publicationPolicyRevision !== 2 || previewManifest.sequenceEpoch !== 1 || + previewManifest.publicationPolicyRevision !== 3 || 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."); diff --git a/.github/workflows/pylon-stable-release.yml b/.github/workflows/pylon-stable-release.yml index 24c7ecc78e3..bcad28067e5 100644 --- a/.github/workflows/pylon-stable-release.yml +++ b/.github/workflows/pylon-stable-release.yml @@ -473,7 +473,7 @@ jobs: --operation "$OPERATION" --policy-sha "${{ github.sha }}" --policy-tree "$policy_tree" - --publication-policy-revision 2 + --publication-policy-revision 3 ) if [ "$OPERATION" = withdraw ]; then args+=(--revoke-tag "$REVOKE_STABLE_TAG" --reason "$REASON") @@ -557,7 +557,7 @@ jobs: if ( manifest.schemaVersion !== 1 || manifest.channel !== "stable" || manifest.repository !== "https://github.com/pylon-code/prime-agent" || - manifest.promotion?.publicationPolicyRevision !== 2 || + manifest.promotion?.publicationPolicyRevision !== 3 || !/^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 @@ -663,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, 2].includes(manifest.build.publicationPolicyRevision) || - manifest.promotion?.policyCommit !== context.sha || manifest.promotion?.publicationPolicyRevision !== 2 + manifest.build.previewTag !== manifest.build.id || ![1, 2, 3].includes(manifest.build.publicationPolicyRevision) || + manifest.promotion?.policyCommit !== context.sha || manifest.promotion?.publicationPolicyRevision !== 3 ) 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"); @@ -712,7 +712,7 @@ jobs: (asset && (asset.name !== "pylon-stable-channel-v1.json" || asset.size !== bytes.length || asset.digest !== `sha256:${digest}`)) ) throw new Error("Existing stable draft identity or asset differs."); if (!asset) { - await github.request("POST /repos/{owner}/{repo}/releases/{release_id}/assets", { + await github.rest.repos.uploadReleaseAsset({ owner, repo, release_id: draft.id, name: "pylon-stable-channel-v1.json", data: bytes, headers: { "content-type": "application/json", "content-length": bytes.length }, }); @@ -816,7 +816,7 @@ jobs: let asset = draft.assets[0]; if (asset && asset.name !== "pylon-stable-channel-v1.json") throw new Error("Stable draft singleton asset has an unexpected name."); if (!asset) { - await github.request("POST /repos/{owner}/{repo}/releases/{release_id}/assets", { + await github.rest.repos.uploadReleaseAsset({ owner, repo, release_id: draft.id, name: "pylon-stable-channel-v1.json", data: bytes, headers: { "content-type": "application/json", "content-length": bytes.length }, }); @@ -840,10 +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, 2].includes(manifest.build.publicationPolicyRevision) || + manifest.build.previewTag !== process.env.PREVIEW_TAG || ![1, 2, 3].includes(manifest.build.publicationPolicyRevision) || manifest.promotion?.kind !== operation || - (mode === "normal" ? manifest.promotion?.publicationPolicyRevision !== 2 : - ![1, 2].includes(manifest.promotion?.publicationPolicyRevision)) || + (mode === "normal" ? manifest.promotion?.publicationPolicyRevision !== 3 : + ![1, 2, 3].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") { diff --git a/.pylon/features.yaml b/.pylon/features.yaml index 9327e33deb8..9c6994327e9 100644 --- a/.pylon/features.yaml +++ b/.pylon/features.yaml @@ -410,7 +410,7 @@ decisions: - https://github.com/pylon-code/prime-agent/issues/53 upstream_refs: - https://github.com/PrimeIntellect-ai/prime-agent/pull/32 - fork_change: protected-publication-with-inode-bound-consumer-generations-v3 + fork_change: protected-publication-with-inode-bound-consumer-generations-v3-and-upload-policy-3 upstream_support: Prime's inherited publication path targets upstream R2 and npm channels and does not provide canonical Pylon-only exact-SHA admission, six immutable preview subjects, byte-preserving manual promotion, signed monotonic stable history, or append-only withdrawal. revisit_when: - Prime ships a repository-neutral immutable release and promotion primitive that preserves Pylon's exact source, workflow, attestation, stable-history, and withdrawal policy without upstream credentials or channel names. diff --git a/.pylon/upstream-review.md b/.pylon/upstream-review.md index 891319ebd2a..fdf54fb9f07 100644 --- a/.pylon/upstream-review.md +++ b/.pylon/upstream-review.md @@ -302,3 +302,11 @@ Follow-up: Task10 builds/packs the exact merged tree into a private prefix and r - Callback staging never publishes before successful return. Exact canonical receipt inodes permit interrupted publication recovery without replaying callbacks. Rotation recovery reconstructs the pre-certificate source when its deterministic rotation claim was linked before the winning index CAS; it excludes only that exact claim and still requires the complete certificate and winning-CAS successor checks. A claim that consumes the remaining heartbeat admission margin is released and rotated before callback entry, preserving recovery at small supported state limits. - Verification gates retain the protected and historical v2 matrices alongside current-public v3 verifier tests, complete mutation-boundary SIGKILL traces with fresh-process recovery, repeated four-process migration/rotation competition, pinned-read handoffs and last-proof replacement negatives. CI requires separate retained/current-public, exhaustive crash and repeated stress suites followed sequentially by the actual 16 MiB maximum on Ubuntu 24.04 and macOS 15 under Node 22.23.2; both results are mandatory in `build-check-test`. Process crashes do not simulate physical power loss, and resource limits are separately bounded historical inventories, metadata and generation-journal budgets. - Revisit only when an upstream primitive preserves these exact local authority, bounded-work, migration and no-replay guarantees. Publication still requires current exact-source checks, independent review and every existing protected environment approval; filesystem tests do not authorize a release. + +## 2026-09-11 — release asset transport and publication policy 3 + +- Reviewed upstream compatibility remains v0.9.4 at `1eee2938b4eeb7a4d72e17035adda669a89b63de`; this correction does not advance the frozen integration range. Current upstream issue/PR searches for release asset upload yielded no matching implementation. The inherited `build-binaries.yml` still publishes through R2 and mutable GitHub upload/clobber paths; it does not replace Pylon's immutable transport contract. +- `protected-pylon-publication`: **redesign**, retaining the existing protected workflow and consumer-generation design. An actual preview reached verified six-subject provenance but its first upload used the generic API host and omitted the filename query. All three preview/stable staging and zero-asset recovery uploads now use the pinned client's `repos.uploadReleaseAsset` endpoint, preserving exact bytes, content length, metadata/digest readback and upload-before-reservation order. +- Publication policy 3 appends exact preview/stable workflow digests. Historical policies 1/2 remain immutable, artifact recipe 1 and runtime 0.9.4 are unchanged, and stable recovery still accepts only explicitly registered historical policies. The matching Pylon consumer entry is required before managed installation. +- Validation: all three inline upload regressions fail on the old generic request and pass with the specialized method. Preview covers six binary subjects, complete/partial draft joins, and altered receipt refusal. Stable normal staging retains crash-after-create recovery; zero-asset recovery verifies body bytes and rejects altered metadata/downloads before policy proof or reservation. An independent offline probe against the exact pinned action bundle proves the upload host, filename encoding and binary body preservation. The full focused publication suite and required repository checks accompany the PR. +- A live corrected protected workflow remains the publication acceptance gate. Preserve the prior source's valid tag and empty draft; do not manually replace assets, expand credentials, or relax approval/CAS controls. Revisit when upstream provides an equivalent immutable, byte-preserving publication primitive. diff --git a/docs/pylon-publication.md b/docs/pylon-publication.md index 07af3679058..8cda986eeca 100644 --- a/docs/pylon-publication.md +++ b/docs/pylon-publication.md @@ -88,7 +88,7 @@ The canonical preview manifest binds the full source commit/tree, artifact recip ```json { - "publicationPolicyRevision": 2, + "publicationPolicyRevision": 3, "sequenceEpoch": 1, "sequence": 123, "workflowRunId": "33428882721" @@ -103,6 +103,10 @@ The approved attester signs exactly six subjects with pinned `actions/attest-bui The directly `pylon-preview`-gated staging job re-reads live `pylon`, then a dedicated App-authenticated step performs the exact combined REST/GraphQL audit immediately before the separate `GITHUB_TOKEN` preview-tag CAS step. The directly gated publisher repeats live branch/tag checks and a fresh combined audit immediately before the separate immutable-release update. GraphQL is the final authoritative read in each audit. A stale admission or earlier audit is irrelevant. GitHub does not offer an atomic transaction across branch reads, tag creation, and release publication. Each read and compare-and-set is a separate fail-closed point-in-time check; this design does not claim cross-resource atomicity. +All preview and stable asset uploads use the pinned GitHub client's `repos.uploadReleaseAsset` method. It selects `uploads.github.com`, encodes the required `name` query parameter, and sends the exact binary body and content length. A generic API-host POST does not supply that transport contract. Preview staging checks every returned asset receipt; stable staging and zero-asset recovery also download and hash the singleton before reservation or publication. + +Policy revision 3 binds these upload workflow bytes. Revisions 1 and 2 retain their original digests for historical verification and explicit stable recovery. A failed preview's valid source tag and empty draft remain attached to that source; a correction on a new source commit uses its own source-derived preview tag. Do not retarget the old tag or attach new-source bytes to its draft. + ## Preview consumer high-water Download one preview into a new directory. The integrated verifier checks all bytes, six attestations, signer workflow at the signer digest, signed run invocation, Actions run number, and then atomically advances explicit consumer-local state: diff --git a/scripts/pylon-prime-supported-release-recipes-v1.json b/scripts/pylon-prime-supported-release-recipes-v1.json index 3d447aeb692..e5559513b4e 100644 --- a/scripts/pylon-prime-supported-release-recipes-v1.json +++ b/scripts/pylon-prime-supported-release-recipes-v1.json @@ -23,6 +23,13 @@ "previewWorkflowSha256": "9f4e3f38fb0bdb9c11662310c5369fb792765a3090e0f74b0ec0b34127b43ed8", "stableWorkflowPath": ".github/workflows/pylon-stable-release.yml", "stableWorkflowSha256": "0f04d1f55f54312d933087d88de6883e8408bb0cd9f060d3b5851d710698b1af" + }, + { + "publicationPolicyRevision": 3, + "previewWorkflowPath": ".github/workflows/pylon-preview-release.yml", + "previewWorkflowSha256": "16f68e46801eccca5e7e99736f346b5ffd96ce7188792d4ac8fbc4580408a736", + "stableWorkflowPath": ".github/workflows/pylon-stable-release.yml", + "stableWorkflowSha256": "96e4f3ccd889a17a391f00b4398d132a4234b5bc337045fedd3605f4eb74c331" } ] } diff --git a/scripts/pylon-publication.test.mjs b/scripts/pylon-publication.test.mjs index 4e4e89e2b6f..c8871c8972f 100644 --- a/scripts/pylon-publication.test.mjs +++ b/scripts/pylon-publication.test.mjs @@ -383,7 +383,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: 3 }, + { ...invocation, publicationPolicyRevision: 999 }, { sequenceEpoch: 1, sequence: 17, workflowRunId: "33428882721" }, ]) assert.throws(() => createPreviewManifest(release, releaseBytes, invalid), /policy revision/); for (const mutate of [ @@ -3135,7 +3135,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 = 3), + (value) => (value.build.publicationPolicyRevision = 999), (value) => (value.build.releaseManifest.file = "other.json"), (value) => (value.build.previewManifest.file = "other.json"), (value) => (value.build.assets[0].file = "../escape.tgz"), @@ -3145,7 +3145,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 = 3), + (value) => (value.promotion.publicationPolicyRevision = 999), ]) { const changed = structuredClone(stable); mutate(changed); @@ -3595,7 +3595,7 @@ test("recipe and publication policy registries close independent immutable ident const policies = registry.publicationPolicies; const historicalPolicy = policies[0]; const policy = policies.at(-1); - assert.deepEqual(policies.map((candidate) => candidate.publicationPolicyRevision), [1, 2]); + assert.deepEqual(policies.map((candidate) => candidate.publicationPolicyRevision), [1, 2, 3]); assert.equal(historicalPolicy.previewWorkflowSha256, "e790a5da7063bd40fbd886e84945c3200291194fdbd5b002079349e45356a41d"); assert.equal(historicalPolicy.stableWorkflowSha256, "dfcecdf6b58f143f9b7a543eadd124c190350ae29ac9eadccb907f1398b0958a"); const preview = readFileSync(join(root, policy.previewWorkflowPath), "utf8"); @@ -3676,11 +3676,11 @@ test("recipe and publication policy registries close independent immutable ident /Administration-gated branch-protection read/, ); - 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.match(preview, /--publication-policy-revision 3/); + assert.match(preview, /preview(?:Manifest)?\.publicationPolicyRevision !== 3/); + assert.match(stable, /--publication-policy-revision 3/); + assert.match(stable, /promotion\?\.publicationPolicyRevision !== 3/); + assert.match(stable, /!\[1, 2, 3\]\.includes\(manifest\.build\.publicationPolicyRevision\)/); assert.throws( () => validateApprovedWorkflowBytes(historicalPolicy.previewWorkflowPath, preview, "preview", 1, policies), /bytes differ/, @@ -3695,6 +3695,15 @@ test("recipe and publication policy registries close independent immutable ident assert.equal(promotedByR2.build.recipeRevision, 1); assert.equal(promotedByR2.build.publicationPolicyRevision, 1); assert.equal(promotedByR2.promotion.publicationPolicyRevision, 2); + assert.equal(policies[1].previewWorkflowSha256, "9f4e3f38fb0bdb9c11662310c5369fb792765a3090e0f74b0ec0b34127b43ed8"); + assert.equal(policies[1].stableWorkflowSha256, "0f04d1f55f54312d933087d88de6883e8408bb0cd9f060d3b5851d710698b1af"); + for (const buildPolicy of [1, 2, 3]) { + const promotedByR3 = structuredClone(promotedByR2); + promotedByR3.build.publicationPolicyRevision = buildPolicy; + promotedByR3.promotion.publicationPolicyRevision = 3; + assert.equal(validateStableManifest(promotedByR3, registry.recipes, policies), promotedByR3); + assert.equal(promotedByR3.build.recipeRevision, 1); + } const stableVerifier = readFileSync(join(root, "scripts/verify-pylon-stable-attestation.mjs"), "utf8"); assert.match(stableVerifier, /manifest\.promotion\.publicationPolicyRevision/); assert.doesNotMatch(stableVerifier, /"stable",\s*manifest\.build\.recipeRevision/); @@ -3702,7 +3711,7 @@ test("recipe and publication policy registries close independent immutable ident assert.match(previewVerifier, /verified\.previewManifest\.publicationPolicyRevision/); for (const mutate of [ (value) => delete value.promotion.publicationPolicyRevision, - (value) => (value.promotion.publicationPolicyRevision = 3), + (value) => (value.promotion.publicationPolicyRevision = 999), (value) => (value.promotion.publicationPolicyRevision = 0), (value) => delete value.build.publicationPolicyRevision, ]) { @@ -6445,7 +6454,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; + manifest.promotion.publicationPolicyRevision = parseSupportedReleaseRecipeRegistry(readFileSync(join(root, "scripts/pylon-prime-supported-release-recipes-v1.json"), "utf8")).publicationPolicies.at(-1).publicationPolicyRevision; const bytes = Buffer.from(canonicalJson(manifest)); const manifestPath = join(fixture, PYLON_STABLE_MANIFEST); writeFileSync(manifestPath, bytes); @@ -6467,13 +6476,20 @@ test("stable stage survives a crash after createRelease by recovering its exact return { data: draft }; }, getRelease: async () => ({ data: draft }), + uploadReleaseAsset: async (request) => { + assert.deepEqual(request, { + owner: "pylon-code", repo: "prime-agent", release_id: 51, + name: PYLON_STABLE_MANIFEST, data: bytes, + headers: { "content-type": "application/json", "content-length": bytes.length }, + }); + draft.assets = [{ id: 9, name: request.name, size: request.data.length, digest: `sha256:${sha256Bytes(request.data)}` }]; + return { data: draft.assets[0] }; + }, }, }, request: async (route, request) => { - if (route.startsWith("POST ")) { - draft.assets = [{ id: 9, name: request.name, size: request.data.length, digest: `sha256:${sha256Bytes(request.data)}` }]; - return { data: draft.assets[0] }; - } + assert.equal(route, "GET /repos/{owner}/{repo}/releases/assets/{asset_id}"); + assert.deepEqual(request, { owner: "pylon-code", repo: "prime-agent", asset_id: 9, headers: { accept: "application/octet-stream" } }); return { data: bytes }; }, }; @@ -6527,7 +6543,8 @@ test("final tag CAS models reject squats and preserve reservation-tag-publish or const reservationCas = publish.indexOf("- name: Create the exact protected stable reservation ref"); const stableTagCas = publish.indexOf("- name: Create or refetch the exact protected stable tag"); const immutablePublish = publish.indexOf("- name: Publish only the exact protected stable draft"); - assert.ok(publish.indexOf('POST /repos/{owner}/{repo}/releases/{release_id}/assets') < reservationCas); + assert.ok(publish.indexOf("github.rest.repos.uploadReleaseAsset(") >= 0); + assert.ok(publish.indexOf("github.rest.repos.uploadReleaseAsset(") < reservationCas); assert.ok(publish.indexOf("downloadedBytes.equals(bytes)") < reservationCas); assert.ok(reservationCas >= 0 && reservationCas < stableTagCas); assert.ok(stableTagCas < immutablePublish); @@ -6696,3 +6713,165 @@ test("workflow static policy proves direct approvals and every contents-write gr } assert.match(attestationVerifier, /verifyApprovedWorkflowAtSignerDigest/); }); + +test("preview draft uploads six exact subjects through the release asset method and joins existing assets", async () => { + const fixture = mkdtempSync(join(tmpdir(), "pylon-preview-upload-")); + const saved = Object.fromEntries(["ARTIFACT_DIR", "GITHUB_RUN_NUMBER", "GITHUB_RUN_ID"].map((key) => [key, process.env[key]])); + try { + const release = fakeReleaseManifest(); + for (const asset of release.assets) { + const bytes = Buffer.from([0, 255, 13, 10, asset.file.length]); + writeFileSync(join(fixture, asset.file), bytes); + asset.size = bytes.length; + asset.sha256 = sha256Bytes(bytes); + asset.sha512 = createHash("sha512").update(bytes).digest("hex"); + } + release.attestationSubjects = release.assets.map((asset) => ({ name: asset.file, digest: { sha256: asset.sha256, sha512: asset.sha512 } })); + const releaseBytes = Buffer.from(JSON.stringify(release)); + const policy = parseSupportedReleaseRecipeRegistry(readFileSync(join(root, "scripts/pylon-prime-supported-release-recipes-v1.json"), "utf8")).publicationPolicies.at(-1).publicationPolicyRevision; + const preview = createPreviewManifest(release, releaseBytes, { ...invocation, publicationPolicyRevision: policy }); + writeFileSync(join(fixture, PYLON_RELEASE_MANIFEST), releaseBytes); + writeFileSync(join(fixture, PYLON_PREVIEW_MANIFEST), canonicalJson(preview)); + Object.assign(process.env, { ARTIFACT_DIR: fixture, GITHUB_RUN_NUMBER: String(invocation.sequence), GITHUB_RUN_ID: invocation.workflowRunId }); + const expected = new Map(readdirSync(fixture).map((name) => [name, readFileSync(join(fixture, name))])); + const uploads = []; + let draft; + let corruptReadback = false; + const listReleases = async () => {}; + const github = { + paginate: async (method) => { + assert.equal(method, listReleases); + return [...(draft ? [draft] : []), { id: 50, tag_name: "pylon-build-gffffffffffff-r1", draft: true, assets: [] }]; + }, + request: async () => { throw new Error("Generic API requests cannot upload release assets"); }, + rest: { + git: { getRef: async ({ ref }) => { + assert.ok(["heads/pylon", `tags/${release.build.id}`].includes(ref)); + return { data: { object: { type: "commit", sha: source.commit } } }; + } }, + repos: { + listReleases, + createRelease: async (request) => { + draft = { ...request, id: 51, immutable: false, assets: [] }; + return { data: structuredClone(draft) }; + }, + uploadReleaseAsset: async (request) => { + const bytes = expected.get(request.name); + assert.ok(bytes); + assert.ok(Buffer.isBuffer(request.data)); + assert.deepEqual(request, { + owner: "pylon-code", repo: "prime-agent", release_id: 51, name: request.name, data: bytes, + headers: { "content-type": "application/octet-stream", "content-length": bytes.length }, + }); + assert.ok(!draft.assets.some((asset) => asset.name === request.name)); + uploads.push(request.name); + const asset = { id: uploads.length, name: request.name, size: bytes.length, digest: `sha256:${sha256Bytes(bytes)}` }; + draft.assets.push(asset); + return { data: asset }; + }, + getRelease: async ({ release_id }) => { + assert.equal(release_id, 51); + const readback = structuredClone(draft); + if (corruptReadback) readback.assets[0].digest = `sha256:${"f".repeat(64)}`; + return { data: readback }; + }, + }, + }, + }; + const context = { repo: { owner: "pylon-code", repo: "prime-agent" }, eventName: "push", ref: PYLON_PUBLICATION_REF, sha: source.commit }; + const execute = new AsyncFunction("github", "context", "core", "require", githubScriptForStep(PYLON_PREVIEW_WORKFLOW, "Create or finish the exact durable draft")); + assert.equal(await execute(github, context, {}, nodeRequire), 51); + assert.deepEqual(uploads, [...expected.keys()].sort()); + assert.equal(await execute(github, context, {}, nodeRequire), 51); + assert.equal(uploads.length, 6, "joining a complete draft must not upload again"); + draft.assets = draft.assets.slice(0, 1); + assert.equal(await execute(github, context, {}, nodeRequire), 51); + assert.equal(uploads.length, 11, "a partial draft uploads only its five missing subjects"); + corruptReadback = true; + await assert.rejects(() => execute(github, context, {}, nodeRequire), /Staged preview asset differs/); + assert.equal(uploads.length, 11); + } finally { + for (const [key, value] of Object.entries(saved)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + rmSync(fixture, { recursive: true, force: true }); + } +}); + +test("stable zero-asset recovery uploads and verifies body bytes before policy checks or reservation", async () => { + const script = githubScriptForStep(PYLON_STABLE_WORKFLOW, "Re-download and validate the exact stable transaction"); + const execute = new AsyncFunction("github", "context", "core", "require", script); + const policyRead = new Error("reached post-upload policy proof"); + const currentPolicy = parseSupportedReleaseRecipeRegistry(readFileSync(join(root, "scripts/pylon-prime-supported-release-recipes-v1.json"), "utf8")).publicationPolicies.at(-1).publicationPolicyRevision; + for (const policy of [1, 2, currentPolicy]) { + for (const corruption of [null, "metadata", "download"]) { + const manifest = firstStable(); + manifest.promotion.publicationPolicyRevision = policy; + const bytes = Buffer.from(canonicalJson(manifest)); + const values = { + DRAFT_ID: "51", MODE: "resume", OPERATION: "resume-promote", POLICY_SHA: source.commit, POLICY_TREE: source.tree, + PREVIEW_TAG: manifest.build.previewTag, RESERVATION_PRESENT: "false", REVOKE_STABLE_TAG: "", REASON: "", + EXPECTED_MANIFEST_SHA256: sha256Bytes(bytes), + }; + const saved = Object.fromEntries(Object.keys(values).map((key) => [key, process.env[key]])); + Object.assign(process.env, values); + try { + const draft = { + id: 51, draft: true, immutable: false, assets: [], + body: publicationReleaseBody({ channel: "stable", tag: manifest.tag, source: source.commit, tree: source.tree, + recipeRevision: 1, policyCommit: source.commit, policyTree: source.tree, stableManifestBytes: bytes }), + }; + const order = []; + const github = { + rest: { + git: { + getRef: async ({ ref }) => { + assert.equal(ref, "heads/pylon"); + return { data: { object: { type: "commit", sha: source.commit } } }; + }, + getCommit: async () => { order.push("policy-proof"); throw policyRead; }, + createTag: async () => { assert.fail("reservation must wait for policy proof"); }, + createRef: async () => { assert.fail("tag CAS must wait for policy proof"); }, + }, + repos: { + getRelease: async ({ release_id }) => { + assert.equal(release_id, 51); + order.push("metadata"); + return { data: structuredClone(draft) }; + }, + uploadReleaseAsset: async (request) => { + assert.ok(Buffer.isBuffer(request.data)); + assert.deepEqual(request, { + owner: "pylon-code", repo: "prime-agent", release_id: 51, name: PYLON_STABLE_MANIFEST, data: bytes, + headers: { "content-type": "application/json", "content-length": bytes.length }, + }); + assert.equal(draft.assets.length, 0); + order.push("upload"); + draft.assets.push({ id: 9, name: PYLON_STABLE_MANIFEST, size: bytes.length, + digest: `sha256:${corruption === "metadata" ? "f".repeat(64) : sha256Bytes(bytes)}` }); + return { data: draft.assets[0] }; + }, + updateRelease: async () => { assert.fail("publication must wait for policy proof"); }, + }, + }, + request: async (route, request) => { + assert.equal(route, "GET /repos/{owner}/{repo}/releases/assets/{asset_id}"); + assert.deepEqual(request, { owner: "pylon-code", repo: "prime-agent", asset_id: 9, headers: { accept: "application/octet-stream" } }); + order.push("download"); + return { data: corruption === "download" ? Buffer.from("altered") : bytes }; + }, + }; + const context = { repo: { owner: "pylon-code", repo: "prime-agent" }, eventName: "workflow_dispatch", ref: PYLON_PUBLICATION_REF, sha: source.commit }; + const core = { setOutput: () => { assert.fail("no authority outputs before policy proof"); } }; + await assert.rejects(() => execute(github, context, core, nodeRequire), corruption ? /singleton differs/ : (error) => error === policyRead); + assert.deepEqual(order, ["metadata", "upload", "metadata", "download", ...(corruption ? [] : ["policy-proof"])]); + } finally { + for (const [key, value] of Object.entries(saved)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + } + } + } +});