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
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,7 @@ jobs:
test "$(node --version)" = "v${PYLON_RELEASE_NODE}"
test "$(npm --version)" = "${PYLON_RELEASE_NPM}"
npm ci
npm run release:pylon:hydrate-runtime

- name: Test release contract
run: npm run test:pylon-release
Expand Down
9 changes: 5 additions & 4 deletions .github/workflows/pylon-preview-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ jobs:
test "$(node --version)" = "v${PYLON_RELEASE_NODE}"
test "$(npm --version)" = "${PYLON_RELEASE_NPM}"
npm ci
npm run release:pylon:hydrate-runtime

- name: Test publication contract
run: |
Expand All @@ -96,7 +97,7 @@ jobs:
- name: Verify and prepare six exact subjects
run: |
npm run release:pylon:verify
npm run release:pylon:preview -- --publication-policy-revision 3
npm run release:pylon:preview -- --publication-policy-revision 4
npm run release:pylon:verify-preview

- name: Upload isolated preview subjects
Expand Down Expand Up @@ -281,7 +282,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 !== 3 || preview.sequenceEpoch !== 1 ||
preview.publicationPolicyRevision !== 4 || 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 @@ -469,7 +470,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 !== 3 || preview.sequenceEpoch !== 1 ||
preview.publicationPolicyRevision !== 4 || 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 @@ -795,7 +796,7 @@ jobs:
previewManifest.build.source.commit !== sourceSha ||
previewManifest.build.source.tree !== releaseManifest.source.tree ||
previewManifest.build.releaseManifest.sha256 !== sha256(releaseBytes) ||
previewManifest.publicationPolicyRevision !== 3 || previewManifest.sequenceEpoch !== 1 ||
previewManifest.publicationPolicyRevision !== 4 || 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.");
Expand Down
14 changes: 7 additions & 7 deletions .github/workflows/pylon-stable-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -473,7 +473,7 @@ jobs:
--operation "$OPERATION"
--policy-sha "${{ github.sha }}"
--policy-tree "$policy_tree"
--publication-policy-revision 3
--publication-policy-revision 4
)
if [ "$OPERATION" = withdraw ]; then
args+=(--revoke-tag "$REVOKE_STABLE_TAG" --reason "$REASON")
Expand Down Expand Up @@ -557,7 +557,7 @@ jobs:
if (
manifest.schemaVersion !== 1 || manifest.channel !== "stable" ||
manifest.repository !== "https://github.com/pylon-code/prime-agent" ||
manifest.promotion?.publicationPolicyRevision !== 3 ||
manifest.promotion?.publicationPolicyRevision !== 4 ||
!/^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 @@ -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, 3].includes(manifest.build.publicationPolicyRevision) ||
manifest.promotion?.policyCommit !== context.sha || manifest.promotion?.publicationPolicyRevision !== 3
manifest.build.previewTag !== manifest.build.id || ![1, 2, 3, 4].includes(manifest.build.publicationPolicyRevision) ||
manifest.promotion?.policyCommit !== context.sha || manifest.promotion?.publicationPolicyRevision !== 4
) 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 @@ -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, 3].includes(manifest.build.publicationPolicyRevision) ||
manifest.build.previewTag !== process.env.PREVIEW_TAG || ![1, 2, 3, 4].includes(manifest.build.publicationPolicyRevision) ||
manifest.promotion?.kind !== operation ||
(mode === "normal" ? manifest.promotion?.publicationPolicyRevision !== 3 :
![1, 2, 3].includes(manifest.promotion?.publicationPolicyRevision)) ||
(mode === "normal" ? manifest.promotion?.publicationPolicyRevision !== 4 :
![1, 2, 3, 4].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
2 changes: 1 addition & 1 deletion .pylon/features.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -394,7 +394,7 @@ decisions:
- https://github.com/pylon-code/pylon/issues/114
- https://github.com/pylon-code/pylon/issues/193
- https://github.com/pylon-code/pylon/issues/194
fork_change: deterministic-pylon-release-artifacts-v1
fork_change: deterministic-self-contained-pylon-release-artifacts-v2
upstream_support: Prime's R2 packer accepts mutable channel and archive inputs, refreshes live model catalogs during normal builds, retains upstream package provenance, and does not produce a Pylon-owned deterministic manifest or integrity-complete internal artifact graph.
revisit_when:
- Pylon no longer maintains a Prime distribution or the upstream artifact recipe can emit the exact fork-owned deterministic contract without publication side effects.
Expand Down
7 changes: 7 additions & 0 deletions .pylon/upstream-review.md
Original file line number Diff line number Diff line change
Expand Up @@ -310,3 +310,10 @@ Follow-up: Task10 builds/packs the exact merged tree into a private prefix and r
- 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.

## 2026-09-11 — self-contained managed runtime archive

- Reviewed compatibility remains Prime v0.9.4 at `1eee2938b4eeb7a4d72e17035adda669a89b63de`. Current upstream release/issue/PR searches found no standalone managed package replacing this contract. Upstream's CLI bundler deliberately externalizes native and interop-sensitive packages, and its SDK remains an unbundled module graph.
- `deterministic-pylon-release-artifacts`: **redesign** as recipe 2. The root archive contains the committed production dependency closure, including nested resolution, installed peers and every locked optional platform package. A separate build-input step acquires exact SHA-512-locked tarballs; offline packaging rechecks them, extracts regular files without lifecycle scripts, and copies the separately packed internal workspace artifacts. No semver resolution, host node_modules copying or installation-time dependency download is permitted.
- The installed smoke consumes the root archive directly and loads the public SDK, native/WASM dependencies and owned daemon contract. Historical recipe 1 retains its explicitly historical npm smoke. Final bundle verification requires every locked runtime package, and Pylon's actual managed parser remains a separate acceptance gate. The frozen upstream range, daemon protocol and four SDK feature tokens are unchanged.
- Publication policy 4 binds the added input hydration and exact current-policy guards; policies 1–3 and recipe 1 remain immutable. Recipe 2 requires its matching Pylon consumer. Revisit only when upstream supplies equivalent self-contained, deterministic installation without weakening source, integrity, platform or ownership guarantees.
8 changes: 8 additions & 0 deletions docs/pylon-publication.md
Original file line number Diff line number Diff line change
Expand Up @@ -243,3 +243,11 @@ Use `--initialize` once, then omit it. The CLI requires the complete contiguous
- **Invalid immutable release:** preserve evidence first. GitHub may require an administrator to temporarily disable immutable releases before exact-id deletion. Delete only the proven invalid release, restore/read back immutable releases immediately, and link every API response in the incident. Never alter a valid published sequence.

Run offline policy tests with `npm run test:pylon-publication` and App-acceptance unit tests with `npm run test:pylon-ruleset-auditor-app`. They cover exact current/historical workflow digests and registry closure, immutable signed attempt evidence, zero-asset crash recovery, deterministic stale recovery, active heartbeats, transaction crash convergence, path-boundary checks, exact required-check paths/apps, preview/stable tag squats and CAS order, withdrawal tuples, rollback state, approval DAGs, every contents writer, pinned actions, no source/download execution in publication writers, mocked App scopes and endpoints, REST and GraphQL redaction, the nonzero canary, and token revocation.

## Self-contained managed artifacts (recipe 2)

Recipe 2 bundles the full production runtime into the root tarball. Before entering the offline build namespace, run `npm run release:pylon:hydrate-runtime` with the pinned toolchain. It acquires only URLs and SHA-512 identities already committed in `package-lock.json`, including every optional platform package. The offline pack rechecks these inputs, retains nested and peer resolution, includes the exact built internal workspaces, and never runs dependency lifecycle scripts. Ambient `node_modules` is not a runtime packaging input.

The final root tarball is checked for every expected runtime package. Both installed-artifact jobs extract that same root directly, with no npm installation, and prove CLI, SDK, native/WASM loading and caller-owned daemon behavior. Historical recipe 1 smoke remains available only for historical publication verification. Pylon also verifies the final archive through its bounded managed installer; signed provenance alone does not prove a runnable package.

The root retains SDK declarations and source maps. npm emits per-file PAX headers for a few dependency basenames longer than 100 bytes; the matching Pylon consumer accepts only bounded path/size/mtime metadata immediately followed by a regular file, preserving path, collision, link and size restrictions. Stable approval still requires successful protected artifact graduation for the exact preview.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
"release:pack": "node scripts/pack-prime-agent-release.mjs",
"release:pylon:build": "node scripts/build-pylon-prime-agent-release.mjs",
"release:pylon:hydrate-lock": "node scripts/hydrate-pylon-release-lock.mjs",
"release:pylon:hydrate-runtime": "node scripts/hydrate-pylon-runtime-inputs.mjs",
"release:pylon:pack": "node scripts/build-pylon-prime-agent-release.mjs --pack",
"release:pylon:verify": "node scripts/verify-pylon-prime-agent-release.mjs",
"release:pylon:preview": "node scripts/prepare-pylon-preview-manifest.mjs",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- Fixed Pylon managed builds to include their exact runtime dependencies for offline installation on Linux and macOS.
2 changes: 1 addition & 1 deletion scripts/build-pylon-prime-agent-release.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ try {
assertCleanSource(root, { rejectIgnoredInputs: true });
writeReleaseBuildReceipt(root, source, toolchain, lockfileSha256);
if (args.pack) {
packPylonPrimeAgentRelease(args.outDir ? ["--out-dir", args.outDir] : [], environment);
await packPylonPrimeAgentRelease(args.outDir ? ["--out-dir", args.outDir] : [], environment);
}
} catch (error) {
console.error(error instanceof Error ? error.message : String(error));
Expand Down
8 changes: 8 additions & 0 deletions scripts/fixtures/publication-crash/worker.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,14 @@ const options = { stateMaxBytes: config.stateMaxBytes ?? 1024, now: () => config
beforeCommitDecision: () => barrier("semantic", { phase: "before", operation: "commit-decision", path: state }),
afterCommitDecision: () => barrier("semantic", { phase: "after", operation: "commit-decision", path: state }),
} };
if (config.pauseLosingBuilderRead) options.afterInitialPathStat = async ({ path, stat }) => {
if (readPaused || !path.includes("/.building-") || basename(path) !== "checkpoint.json") return;
readPaused = true;
await send({ type: "cut", pid: process.pid, phase: "losing-builder-before-open", path, identity: { dev: stat.dev, ino: stat.ino } });
await new Promise((resolve, reject) => process.once("message", (message) => {
if (message?.type === "release") resolve(); else reject(new Error("Unexpected losing-builder barrier release."));
}));
};
if (config.pauseRead === "receipt-") options.lstatEntry = async (path) => {
const stat = await lstat(path);
if (basename(path).startsWith("receipt-")) await pauseRead(path, "receipt-");
Expand Down
11 changes: 11 additions & 0 deletions scripts/hydrate-pylon-runtime-inputs.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
#!/usr/bin/env node
import { resolve } from "node:path";
import { hydrateRuntimeDependencies } from "./lib/pylon-runtime-dependencies.mjs";

try {
const count = await hydrateRuntimeDependencies(resolve(import.meta.dirname, ".."));
console.log(`Verified inputs for ${count} locked runtime packages, including all platforms.`);
} catch (error) {
console.error(error instanceof Error ? error.message : String(error));
process.exit(1);
}
5 changes: 4 additions & 1 deletion scripts/lib/pylon-release.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
mkdirSync,
readFileSync,
readdirSync,
realpathSync,
renameSync,
rmSync,
statSync,
Expand Down Expand Up @@ -310,6 +311,7 @@ export function createReleasePackageJson({
delete packageJson.private;

if (releasePackage.publicPackage) {
packageJson.bundleDependencies = Object.keys({ ...packageJson.dependencies, ...packageJson.optionalDependencies }).sort();
packageJson.bin = { "prime-agent": "dist/bundle/cli.js" };
packageJson.piConfig = { ...(packageJson.piConfig || {}), name: "prime-agent", configDir: ".prime/agent" };
}
Expand Down Expand Up @@ -480,7 +482,8 @@ export function prepareOutputDirectory(outDir) {
}

export function packStagingPackage({ root, stagingDir, artifactsDir, assetFile, environment }) {
const output = runNpm(["pack", stagingDir, "--pack-destination", artifactsDir, "--silent"], {
// npm omits bundled dependencies when an ancestor of the package path is a symlink.
const output = runNpm(["pack", realpathSync(stagingDir), "--pack-destination", artifactsDir, "--silent"], {
cwd: root,
env: environment,
forwardStderr: true,
Expand Down
Loading
Loading