diff --git a/CHANGELOG.md b/CHANGELOG.md index 260750dd..03da7704 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ Programa is a fork of [cmux](https://github.com/manaflow-ai/cmux); for history p - New setting, off by default: open a browser split beside the terminal whenever a new agent workspace is created (⌘⇧C, `programa` helper agents, and `race`). Also `automation.openBrowserWithAgentSplits` in settings.json. ### Fixed +- Release publishing accepts the previous ten-asset candidate manifests again, so the first ship after the remote daemon removal no longer fails. - Remote and local CLI clients now share the v2 JSON-RPC and `programa-relay-auth` contracts, password-protected sockets work through MCP, and remote bootstrap files, tmux wait signals, relay diagnostics, and downloaded daemon artifacts have bounded ownership and lifetime. - Session recovery now rejects oversized or structurally invalid snapshots before reconstruction, quarantines corrupt primaries, caps history scanning, elects escrow holders without unlinking live or indeterminate sockets, and terminates timed-out subprocess trees without leaving descendants or pipe readers behind. - The iOS client now uses device-only Keychain credentials, bounded newline framing, cancellation-safe request and pairing deadlines, settled path selection, server-reconciled CloudKit subscriptions, and a generation-owned reconnect loop. The completed duplicate macOS transport spike has been removed. diff --git a/scripts/rolling_release_state.js b/scripts/rolling_release_state.js index daf63041..0ed118c3 100644 --- a/scripts/rolling_release_state.js +++ b/scripts/rolling_release_state.js @@ -50,6 +50,22 @@ function requiredImmutableNames(build) { ]; } +// The remote daemon (programad-remote) was removed in PR #329, which narrowed +// requiredImmutableNames to just the two current build artifacts. The already-published +// prerelease that the live appcast points at was sealed before that change and still +// carries these six legacy daemon immutables. It cannot be deleted, so the validator +// must keep accepting its sealed manifest as-is instead of rejecting it as corrupt. +function legacyImmutableNames(build) { + return [ + `programad-remote-checksums-${build}.txt`, + `programad-remote-darwin-amd64-${build}`, + `programad-remote-darwin-arm64-${build}`, + `programad-remote-linux-amd64-${build}`, + `programad-remote-linux-arm64-${build}`, + `programad-remote-manifest-${build}.json`, + ]; +} + function validateAsset(asset, index) { const label = `manifest asset ${index}`; assertPlainObject(asset, label); @@ -109,6 +125,7 @@ function validateCandidateManifest(manifest) { const stableAliases = assets.filter((asset) => asset.role === "stable-alias"); const immutable = assets.filter((asset) => asset.role === "immutable"); const requiredNames = new Set(requiredImmutableNames(manifest.build)); + const legacyNames = new Set(legacyImmutableNames(manifest.build)); for (const asset of assets) { if (asset.name === "appcast.xml" && asset.role !== "appcast") { @@ -123,13 +140,21 @@ function validateCandidateManifest(manifest) { if (asset.role === "stable-alias" && asset.name !== "programa-macos.dmg") { throw new TypeError("the stable-alias role is reserved for programa-macos.dmg"); } - if (asset.role === "immutable" && !requiredNames.has(asset.name)) { + if (asset.role === "immutable" && !requiredNames.has(asset.name) && !legacyNames.has(asset.name)) { throw new TypeError(`manifest has an unexpected immutable asset or build suffix: ${asset.name}`); } } if (manifest.sealed) { - if (assets.length !== 4) throw new TypeError("sealed manifest must contain exactly 4 assets"); + const presentLegacyNames = immutable.filter((asset) => legacyNames.has(asset.name)); + const isLegacyManifest = presentLegacyNames.length > 0; + if (isLegacyManifest && presentLegacyNames.length !== legacyNames.size) { + throw new TypeError("sealed manifest has a partial legacy daemon asset set"); + } + const expectedAssetCount = isLegacyManifest ? 10 : 4; + if (assets.length !== expectedAssetCount) { + throw new TypeError(`sealed manifest must contain exactly ${expectedAssetCount} assets`); + } if (appcast.length !== 1 || stableAliases.length !== 1) { throw new TypeError("sealed manifest must contain exactly one appcast and one stable alias"); } diff --git a/scripts/rolling_release_state.test.js b/scripts/rolling_release_state.test.js index a98ae52f..d81b6ac5 100644 --- a/scripts/rolling_release_state.test.js +++ b/scripts/rolling_release_state.test.js @@ -267,6 +267,56 @@ test("immutable payload suffixes must match the candidate build", () => { assert.throws(() => validateCandidateManifest(value), /build|suffix|asset|required/i); }); +// PR #329 removed the remote daemon (programad-remote) and narrowed the required +// immutable set from ten assets to two. The already-published rolling candidate that +// the live appcast points at was sealed before that change and still carries the six +// legacy daemon immutables, so the validator must keep accepting it. +function legacyDaemonAssets(build) { + return [ + `programad-remote-checksums-${build}.txt`, + `programad-remote-darwin-amd64-${build}`, + `programad-remote-darwin-arm64-${build}`, + `programad-remote-linux-amd64-${build}`, + `programad-remote-linux-arm64-${build}`, + `programad-remote-manifest-${build}.json`, + ].map((name, index) => ({ + name, + role: "immutable", + size: index + 100, + sha256: String(index + 100).padStart(64, "0"), + })); +} + +test("a sealed manifest with the full legacy ten-asset daemon set still validates", () => { + const value = manifestFor("41"); + value.assets.push(...legacyDaemonAssets("41")); + + const validated = validateCandidateManifest(value); + assert.equal(validated.assets.length, 10); +}); + +test("a sealed manifest with a partial legacy daemon asset set fails closed", () => { + const value = manifestFor("41"); + value.assets.push(...legacyDaemonAssets("41").slice(0, 3)); + + assert.throws(() => validateCandidateManifest(value), /partial|legacy|daemon|asset/i); +}); + +test("an immutable asset with an unknown, non-legacy name still fails validation", () => { + const value = manifestFor("41"); + value.assets.push({ + name: "programad-remote-windows-amd64-41", + role: "immutable", + size: 900, + sha256: "e".repeat(64), + }); + + assert.throws( + () => validateCandidateManifest(value), + /unexpected immutable asset or build suffix/i, + ); +}); + test("the stable DMG is byte-identical to the immutable build DMG", () => { const valid = manifestFor("41"); assert.doesNotThrow(() => validateCandidateManifest(valid));