From a93e825a4831c06f72996001490a1aff6860ebd4 Mon Sep 17 00:00:00 2001 From: 0thernet <894119+0thernet@users.noreply.github.com> Date: Mon, 5 Oct 2026 19:11:09 -0400 Subject: [PATCH 1/2] Restore release-publication authority modes and release v0.18.87 The promotion-lattice retirement removed the publication-prewrite and publication-postwrite modes from release-ref-authority.ts while github-release-publish.ts still invokes them, so the immutable Release step fails at the authority check. Restore both modes as bindings over verifyReleaseRefAuthority that additionally pin the expected release commit and authenticated current main before emitting the receipt, and cover the argument grammar with CLI regressions. v0.18.86 was tagged but never published; this release carries its changes forward. --- CHANGELOG.md | 7 +++ README.md | 10 ++-- dist/apple-photos-client.js | 2 +- dist/beeper-client.js | 2 +- dist/{index-0c7jvbdw.js => index-azzd31r0.js} | 2 +- docs/publishing.md | 4 +- kb/launch/social-kit.md | 14 ++--- package.json | 2 +- scripts/npm-release-workflow.test.ts | 20 +++---- scripts/package-budget.ts | 16 ++++-- scripts/release-ref-authority.test.ts | 54 +++++++++++++++++++ scripts/release-ref-authority.ts | 31 +++++++++-- skills/ghostget/references/install.md | 8 +-- src/beeper-client-types.ts | 2 +- src/media/manifest.test.ts | 2 +- src/version.ts | 2 +- website/build.ts | 2 +- 17 files changed, 137 insertions(+), 43 deletions(-) rename dist/{index-0c7jvbdw.js => index-azzd31r0.js} (62%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 92b8303a..7d4089cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,13 @@ Historical entries retain their original delivery coordinates. ## Unreleased +## 0.18.87 + +Restore the release-publication authority modes the GitHub Release publisher invokes. + +- `release-ref-authority.ts` accepts `publication-prewrite` and `publication-postwrite` again: each verifies the governed refs exactly as `release` does, then binds the caller's expected release commit and authenticated main commit before emitting the receipt. This repairs the immutable Release step that failed after the promotion-lattice retirement removed the modes. +- v0.18.86 was tagged but never published; this release contains its changes verbatim. + ## 0.18.86 Publish MP4 video on LinkedIn, Threads, and Substack and repair the post-create response bindings on four providers. diff --git a/README.md b/README.md index f3891057..51879327 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ Install [Bun 1.3.14](https://bun.sh/docs/installation) if needed, then install GhostGet and read a public page: ```sh -bun add --global https://github.com/hraness/ghostget/releases/download/v0.18.86/hraness-ghostget-0.18.86.tgz +bun add --global https://github.com/hraness/ghostget/releases/download/v0.18.87/hraness-ghostget-0.18.87.tgz ghostget read https://example.com ``` @@ -48,9 +48,9 @@ which always names the latest published release. Upgrading from Wrench? Read the The optional Agent Skill teaches your agent when and how to use GhostGet: ```sh -npx skills add hraness/ghostget#v0.18.86 +npx skills add hraness/ghostget#v0.18.87 # With Bun instead: -bunx skills add hraness/ghostget#v0.18.86 +bunx skills add hraness/ghostget#v0.18.87 ``` Start a new agent session, then ask: “Use GhostGet to read https://example.com @@ -182,7 +182,7 @@ firewall. ## Built-in provider catalog -This v0.18.86 source tree supports executable actions for 21 services: Beeper, +This v0.18.87 source tree supports executable actions for 21 services: Beeper, Bluesky, ClasificadosOnline, Facebook, Facebook Groups, Facebook Marketplace, GitHub, Gmail, Hacker News, Instagram, iMessage, LinkedIn, Reddit, Substack, Threads, TikTok, Twitch, WebMCP Registry, WhatsApp, X, and YouTube. LinkedIn @@ -266,7 +266,7 @@ For that same released coordinate, install GhostGet in an agent or application that owns its own model, planning, tool loop, approvals, and interface: ```sh -bun add https://github.com/hraness/ghostget/releases/download/v0.18.86/hraness-ghostget-0.18.86.tgz +bun add https://github.com/hraness/ghostget/releases/download/v0.18.87/hraness-ghostget-0.18.87.tgz ``` ```ts diff --git a/dist/apple-photos-client.js b/dist/apple-photos-client.js index caa8eddf..bcf02e04 100644 --- a/dist/apple-photos-client.js +++ b/dist/apple-photos-client.js @@ -1,7 +1,7 @@ // @bun import { GHOSTGET_VERSION -} from "./index-0c7jvbdw.js"; +} from "./index-azzd31r0.js"; import { canonicalJson, sha256 diff --git a/dist/beeper-client.js b/dist/beeper-client.js index fd1392c0..3ba11f09 100644 --- a/dist/beeper-client.js +++ b/dist/beeper-client.js @@ -1,7 +1,7 @@ // @bun import { GHOSTGET_VERSION -} from "./index-0c7jvbdw.js"; +} from "./index-azzd31r0.js"; import { canonicalJson, canonicalJsonSha256Matches, diff --git a/dist/index-0c7jvbdw.js b/dist/index-azzd31r0.js similarity index 62% rename from dist/index-0c7jvbdw.js rename to dist/index-azzd31r0.js index cda99862..1d1bca9a 100644 --- a/dist/index-0c7jvbdw.js +++ b/dist/index-azzd31r0.js @@ -1,5 +1,5 @@ // @bun // src/version.ts -var GHOSTGET_VERSION = "0.18.86"; +var GHOSTGET_VERSION = "0.18.87"; export { GHOSTGET_VERSION }; diff --git a/docs/publishing.md b/docs/publishing.md index 2a72e14e..931ecdd4 100644 --- a/docs/publishing.md +++ b/docs/publishing.md @@ -332,12 +332,12 @@ changes the immutable Release or its tag. ## Install the canonical release -These commands require the matching published immutable v0.18.85 release. +These commands require the matching published immutable v0.18.87 release. For the CLI: ```sh -bun add --global https://github.com/hraness/ghostget/releases/download/v0.18.86/hraness-ghostget-0.18.86.tgz +bun add --global https://github.com/hraness/ghostget/releases/download/v0.18.87/hraness-ghostget-0.18.87.tgz ghostget --version ghostget doctor --json ``` diff --git a/kb/launch/social-kit.md b/kb/launch/social-kit.md index d70b8bfd..bd512bd5 100644 --- a/kb/launch/social-kit.md +++ b/kb/launch/social-kit.md @@ -57,7 +57,7 @@ The plan is for GhostGet to stay small. Your agent does the thinking, and GhostG Post 9 of 9, 192 characters ```text -GhostGet is free, MIT licensed, and runs on macOS and Linux. The first step reads a public page and needs no account. Latest release: v0.18.86. +GhostGet is free, MIT licensed, and runs on macOS and Linux. The first step reads a public page and needs no account. Latest release: v0.18.87. https://ghostget.com/blog/introducing-ghostget/ ``` @@ -115,7 +115,7 @@ The plan is for GhostGet to stay small. Your agent does the thinking, and GhostG Post 9 of 9, 192 characters ```text -GhostGet is free, MIT licensed, and runs on macOS and Linux. The first step reads a public page and needs no account. Latest release: v0.18.86. +GhostGet is free, MIT licensed, and runs on macOS and Linux. The first step reads a public page and needs no account. Latest release: v0.18.87. https://ghostget.com/blog/introducing-ghostget/ ``` @@ -173,7 +173,7 @@ The plan is for GhostGet to stay small. Your agent does the thinking, and GhostG Post 9 of 9, 192 characters ```text -GhostGet is free, MIT licensed, and runs on macOS and Linux. The first step reads a public page and needs no account. Latest release: v0.18.86. +GhostGet is free, MIT licensed, and runs on macOS and Linux. The first step reads a public page and needs no account. Latest release: v0.18.87. https://ghostget.com/blog/introducing-ghostget/ ``` @@ -197,7 +197,7 @@ GhostGet is for Claude Code, Codex, Cursor, and other agents that run commands o The plan is for GhostGet to stay small. Your agent does the thinking, and GhostGet runs only actions someone has reviewed. Each new service arrives as reviewed actions with their own previews. -GhostGet is free, MIT licensed, and runs on macOS and Linux. The first step reads a public page and needs no account. Latest release: v0.18.86. +GhostGet is free, MIT licensed, and runs on macOS and Linux. The first step reads a public page and needs no account. Latest release: v0.18.87. https://ghostget.com/blog/introducing-ghostget/ ``` @@ -224,8 +224,8 @@ Topics: Developer Tools, Artificial Intelligence, Open Source - Sometimes a post goes through but the answer gets lost on the way back. GhostGet writes down every send before it leaves and never sends it again on its own until it knows what happened. - Anything beyond a read starts as a preview that shows the service, the account, and exactly what will be sent. Your agent can prepare it. Nothing is sent until someone confirms that exact preview. - GhostGet is for Claude Code, Codex, Cursor, and other agents that run commands on your Mac or Linux machine and need to read the web and use the accounts you already have. -- GhostGet is free, MIT licensed, and runs on macOS and Linux. The first step reads a public page and needs no account. Latest release: v0.18.86. -- Latest release: v0.18.86. https://ghostget.com/blog/introducing-ghostget/ +- GhostGet is free, MIT licensed, and runs on macOS and Linux. The first step reads a public page and needs no account. Latest release: v0.18.87. +- Latest release: v0.18.87. https://ghostget.com/blog/introducing-ghostget/ ## Beats @@ -251,4 +251,4 @@ Topics: Developer Tools, Artificial Intelligence, Open Source - claimsTotal: 211. verification/claims.json, every claim - claimsEvidenced: 198. verification/claims.json, status evidenced - claimsConfigReadback: 9. verification/claims.json, layer configuration-readback -- status: Latest release: v0.18.86. package.json version +- status: Latest release: v0.18.87. package.json version diff --git a/package.json b/package.json index f04b66fd..200c9278 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@hraness/ghostget", - "version": "0.18.86", + "version": "0.18.87", "description": "GhostGet gives your AI agent named web actions: read a page, archive one media item, or use a connected account, without credentials or a browser to steer.", "license": "MIT", "type": "module", diff --git a/scripts/npm-release-workflow.test.ts b/scripts/npm-release-workflow.test.ts index 7f5c6256..b8eb9ae1 100644 --- a/scripts/npm-release-workflow.test.ts +++ b/scripts/npm-release-workflow.test.ts @@ -359,7 +359,7 @@ describe("npm publication contract", () => { (MAX_UNPACKED_BYTES + MAX_PACKED_ENTRIES * 1_023 + 1_024) / 512, ) * 512, ); - expect(MAX_PACKAGE_TAR_BYTES).toBe(25_070_080); + expect(MAX_PACKAGE_TAR_BYTES).toBe(25_070_592); expect(MAX_PACKAGE_TAR_BYTES % 512).toBe(0); expect(artifact).toContain("maxOutputLength: MAX_PACKAGE_TAR_BYTES"); expect(artifact).not.toContain("const maximumTarBytes"); @@ -569,10 +569,10 @@ describe("npm publication contract", () => { expect(budget).toContain("785b8fa60c329d7ac46bc8fcf4d959b5fa9d96455bba9e7cdea63f6a3827c4f6"); expect(Object.isFrozen(repairPackageMeasurement)).toBeTrue(); expect(repairPackageMeasurement).toMatchObject({ - scope: "0.18.86 media.publish video lanes and create-response binding repairs", + scope: "0.18.87 release-publication authority repair", npmVersion: "11.19.0", nodeVersion: "24.20.0", zlibVersion: "1.3.2.1-motley-42c2f19", - archiveSha256: "78df7ff4f1934aeef092ad55cdbfcb8c8b5d6cf3543561044653afe14d95766d", - packedBytes: 12_231_057, unpackedBytes: 24_414_834, entryCount: 639, + archiveSha256: "753ffbeb39d4606f252d299c971fa32ae06c8927b8a4037fb72f349a5c527c9f", + packedBytes: 12_231_242, unpackedBytes: 24_415_392, entryCount: 639, packedPlatformProjection: 12_387, packedPortabilityAllowance: 4_096, payloadPlatformProjection: 353, payloadAllowance: 65, }); @@ -588,8 +588,8 @@ describe("npm publication contract", () => { expect(budget).toContain("24,024,705 + 353 + 65 = 24,025,123 unpacked"); expect(budget).toContain("12,152,562 + 12,387 + 4,096 = 12,169,045 packed"); expect(budget).toContain("24,093,786 + 353 + 65 = 24,094,204 unpacked"); - expect(MAX_PACKED_BYTES).toBe(12_247_540); - expect(MAX_PACKED_BYTES).toBe(12_231_057 + 12_387 + 4_096); + expect(MAX_PACKED_BYTES).toBe(12_247_725); + expect(MAX_PACKED_BYTES).toBe(12_231_242 + 12_387 + 4_096); expect(budget).toContain("aa127b3193c9bb3b0cb5deece5927be60ccb7111a50169320d322ffdeaa13f39"); expect(budget).toContain("0c331bab3ab3df69a108e18f5f29845b0db90c281cbd6455c0d90fa0b24081e2"); expect(budget).toContain("873cad8139fda303e2d19c6afd61cf549cf9b4d1d76b2a1d6d632a6afe6bd0d1"); @@ -710,7 +710,7 @@ describe("npm publication contract", () => { expect(budget).toContain("47684b3e2eb5cf3ed07fbb520aade8c7251d993f75262fbf1af627d9081a1a5f"); expect(budget).toContain("23,688,277 + 353 + 65 = 23,688,695"); expect(budget).toContain("23,759,283 + 353 + 65 = 23,759,701"); - expect(MAX_UNPACKED_BYTES).toBe(24_415_252); + expect(MAX_UNPACKED_BYTES).toBe(24_415_810); expect(budget).toContain("23,037,873 + 65 = 23,037,938"); expect(budget).toContain("f9f3ab38a682690ceaa2699a7309997512030f0fa500a9dc29dcd108123dc41f"); expect(budget).toContain("23,038,557 + 65 = 23,038,622"); @@ -743,7 +743,7 @@ describe("npm publication contract", () => { expect(budget).toContain("01875f12ab73a49d6c7d6bf520dc3d318db816addee2fa7981889f35c958cf7c"); expect(budget).toContain("b12909f08f7c19460ced56e30619f4860a1183f4b0106170c07837dae577a937"); expect(budget).toContain("0b212ac291218528dcf979370110a36f10850e046ca90a536057d9a44e807d1d"); - expect(MAX_UNPACKED_BYTES).toBe(24_414_834 + 353 + 65); + expect(MAX_UNPACKED_BYTES).toBe(24_415_392 + 353 + 65); expect(budget).toContain("22,794,052 + 65 = 22,794,117"); expect(budget).toContain("c482efe748f880e3717727d6d39fd92a68953e6eea766642b329ba47ae772d80"); expect(budget).toContain("22,759,423 + 65 = 22,759,488"); @@ -779,8 +779,8 @@ describe("npm publication contract", () => { expect(packageArtifactBudget).toEqual({ entryCount: { min: 639, max: 639 }, fileCount: { min: 639, max: 639 }, - packedBytes: { min: 1_600_000, max: 12_247_540 }, - unpackedBytes: { min: 9_000_000, max: 24_415_252 }, + packedBytes: { min: 1_600_000, max: 12_247_725 }, + unpackedBytes: { min: 9_000_000, max: 24_415_810 }, }); }); diff --git a/scripts/package-budget.ts b/scripts/package-budget.ts index 2b6510e5..91ac7e1e 100644 --- a/scripts/package-budget.ts +++ b/scripts/package-budget.ts @@ -2518,16 +2518,24 @@ // 78df7ff4f1934aeef092ad55cdbfcb8c8b5d6cf3543561044653afe14d95766d. Retain the // reviewed allowances: 12,231,057 + 12,387 + 4,096 = 12,247,540 packed; // 24,414,834 + 353 + 65 = 24,415,252 unpacked. +// The 0.18.87 release restores the release-publication authority modes the +// canonical GitHub Release publisher invokes. Two clean npm 11.19.0 packs on +// Node 24.20.0 (zlib 1.3.2.1-motley-42c2f19) with --ignore-scripts on darwin +// arm64 were byte-identical at 639 files/entries, 12,231,242 packed bytes +// and 24,415,392 unpacked bytes; archive SHA-256 +// 753ffbeb39d4606f252d299c971fa32ae06c8927b8a4037fb72f349a5c527c9f. Retain the +// reviewed allowances: 12,231,242 + 12,387 + 4,096 = 12,247,725 packed; +// 24,415,392 + 353 + 65 = 24,415,810 unpacked. export const repairPackageMeasurement = Object.freeze({ - scope: "0.18.86 media.publish video lanes and create-response binding repairs", + scope: "0.18.87 release-publication authority repair", command: "npm pack --ignore-scripts", npmVersion: "11.19.0", nodeVersion: "24.20.0", zlibVersion: "1.3.2.1-motley-42c2f19", platform: "darwin-arm64", - archiveSha256: "78df7ff4f1934aeef092ad55cdbfcb8c8b5d6cf3543561044653afe14d95766d", - packedBytes: 12_231_057, - unpackedBytes: 24_414_834, + archiveSha256: "753ffbeb39d4606f252d299c971fa32ae06c8927b8a4037fb72f349a5c527c9f", + packedBytes: 12_231_242, + unpackedBytes: 24_415_392, entryCount: 639, packedPlatformProjection: 12_387, packedPortabilityAllowance: 4_096, diff --git a/scripts/release-ref-authority.test.ts b/scripts/release-ref-authority.test.ts index 566b1621..9144c3f1 100644 --- a/scripts/release-ref-authority.test.ts +++ b/scripts/release-ref-authority.test.ts @@ -319,6 +319,60 @@ describe("Ghostget release ref authority", () => { }, ); + test.each([ + ["publication-prewrite", "v1.0.0", "not-a-sha", "0".repeat(40), "one exact release commit"], + ["publication-postwrite", "v1.0.0", "0".repeat(40), "not-a-sha", "one authenticated main commit"], + ])( + "rejects malformed publication authority coordinates before remote inspection (%s)", + (...rest) => { + const arguments_ = rest.slice(0, 4) as [string, string, string, string]; + const result = spawnSync("node", [ + "--experimental-strip-types", join(import.meta.dir, "release-ref-authority.ts"), + ...arguments_, + ], { + encoding: "utf8", + env: { + PATH: process.env.PATH ?? "", + GITHUB_REPOSITORY: "hraness/ghostget", + DEFAULT_BRANCH: "main", + }, + stdio: ["ignore", "pipe", "pipe"], + maxBuffer: 64 * 1_024, + timeout: 10_000, + }); + expect(result.error).toBeUndefined(); + expect(result.status).toBe(1); + expect(result.stdout).toBe(""); + expect(result.stderr).toContain(`Publication authority requires ${rest[4]}`); + }, + ); + + test.each([ + ["publication-prewrite", "v1.0.0"], + ["publication-postwrite", "v1.0.0", "0".repeat(40)], + ])( + "rejects incomplete publication authority arguments (%s)", + (...arguments_) => { + const result = spawnSync("node", [ + "--experimental-strip-types", join(import.meta.dir, "release-ref-authority.ts"), + ...arguments_, + ], { + encoding: "utf8", + env: { + PATH: process.env.PATH ?? "", + GITHUB_REPOSITORY: "hraness/ghostget", + DEFAULT_BRANCH: "main", + }, + stdio: ["ignore", "pipe", "pipe"], + maxBuffer: 64 * 1_024, + timeout: 10_000, + }); + expect(result.error).toBeUndefined(); + expect(result.status).toBe(1); + expect(result.stderr).toContain("Usage: release-ref-authority.ts release TAG"); + }, + ); + test("accepts one lightweight release tag below protected current main", () => { const input = fixture(); checkoutRelease(input); diff --git a/scripts/release-ref-authority.ts b/scripts/release-ref-authority.ts index 7b24d854..6b14517b 100644 --- a/scripts/release-ref-authority.ts +++ b/scripts/release-ref-authority.ts @@ -460,11 +460,36 @@ function assertWorkflowIdentity(): void { function main(): void { assertWorkflowIdentity(); - const [mode, first, ...extra] = process.argv.slice(2); - if (extra.length > 0 || mode !== "release" || first === undefined) { - fail("Usage: release-ref-authority.ts release TAG"); + const [mode, first, second, third, ...extra] = process.argv.slice(2); + const publication = mode === "publication-prewrite" || mode === "publication-postwrite"; + if ( + extra.length > 0 + || first === undefined + || (mode === "release" && (second !== undefined || third !== undefined)) + || (mode !== "release" && !publication) + || (publication && (second === undefined || third === undefined)) + ) { + fail( + "Usage: release-ref-authority.ts release TAG | " + + "publication-prewrite TAG RELEASE_SHA MAIN_SHA | " + + "publication-postwrite TAG RELEASE_SHA MAIN_SHA", + ); + } + if (publication) { + if (second === undefined || !SHA.test(second)) { + fail("Publication authority requires one exact release commit."); + } + if (third === undefined || !SHA.test(third)) { + fail("Publication authority requires one authenticated main commit."); + } } const authority = verifyReleaseRefAuthority({ requestedTag: first }); + if (second !== undefined && authority.sha !== second) { + fail("Verified release commit does not match the expected release commit."); + } + if (third !== undefined && authority.mainSha !== third) { + fail("Verified advertised main does not match the authenticated current main."); + } process.stdout.write(`sha=${authority.sha}\ntag=${authority.tag}\nmain_sha=${authority.mainSha}\n`); } diff --git a/skills/ghostget/references/install.md b/skills/ghostget/references/install.md index e06f039a..b6a13d6b 100644 --- a/skills/ghostget/references/install.md +++ b/skills/ghostget/references/install.md @@ -17,14 +17,14 @@ If Bun is missing, stop and direct the user to the official [Bun installation guide](https://bun.sh/docs/installation). Do not switch package managers or pipe an unreviewed installer into a shell. -This reference is authored for the exact v0.18.85 release coordinate. Use it +This reference is authored for the exact v0.18.87 release coordinate. Use it only from the matching release-bound Agent Skill after its canonical archive and immutable GitHub Release exist. If the coordinate is not public, stop instead of substituting `main`, another tag, or a different package version. Install that exact release and verify a public-page read: ```sh -bun add --global https://github.com/hraness/ghostget/releases/download/v0.18.86/hraness-ghostget-0.18.86.tgz +bun add --global https://github.com/hraness/ghostget/releases/download/v0.18.87/hraness-ghostget-0.18.87.tgz ghostget read https://example.com ``` @@ -54,14 +54,14 @@ wait for registry publication. When upgrading from Wrench, use `ghostget` for new commands and `GHOSTGET_STATE_HOME` for an explicit state root. Existing state is selected in place; do not rename, copy, or delete a state directory as part of the upgrade. -The [migration guide](https://github.com/hraness/ghostget/blob/v0.18.85/docs/ghostget-migration.md) +The [migration guide](https://github.com/hraness/ghostget/blob/v0.18.87/docs/ghostget-migration.md) explains the retained state aliases and durable protocol names. Do not clone the repository merely to run the CLI. Importing the SDK is a separate project dependency and does not install a global command: ```sh -bun add https://github.com/hraness/ghostget/releases/download/v0.18.86/hraness-ghostget-0.18.86.tgz +bun add https://github.com/hraness/ghostget/releases/download/v0.18.87/hraness-ghostget-0.18.87.tgz ``` `ghostget adapter sync-bundled` upgrades exact bundled baselines, including an diff --git a/src/beeper-client-types.ts b/src/beeper-client-types.ts index 9ea44810..46c426ea 100644 --- a/src/beeper-client-types.ts +++ b/src/beeper-client-types.ts @@ -96,7 +96,7 @@ export type BeeperContactInteractionExportReceipt = Readonly<{ implementation: Readonly<{ producer: Readonly<{ package: "@hraness/ghostget"; - version: "0.18.86"; + version: "0.18.87"; }>; officialCli: Readonly<{ implementation: "github.com/beeper/cli"; diff --git a/src/media/manifest.test.ts b/src/media/manifest.test.ts index 05963713..6442c377 100644 --- a/src/media/manifest.test.ts +++ b/src/media/manifest.test.ts @@ -465,7 +465,7 @@ function trackedYtDlpManifest( describe("Ghostget media manifest", () => { test("uses one Ghostget-owned schema and transcriber identity", () => { expect(GHOSTGET_MEDIA_SCHEMA_VERSION).toBe(1); - expect(GHOSTGET_MEDIA_VERSION).toBe("0.18.86"); + expect(GHOSTGET_MEDIA_VERSION).toBe("0.18.87"); expect(localTranscriptVariantSegments(localIdentity)).toEqual([ "transcript", "local", diff --git a/src/version.ts b/src/version.ts index c08731f3..f6dc6579 100644 --- a/src/version.ts +++ b/src/version.ts @@ -1,2 +1,2 @@ /** Canonical immutable Ghostget package release identity. */ -export const GHOSTGET_VERSION = "0.18.86" as const; +export const GHOSTGET_VERSION = "0.18.87" as const; diff --git a/website/build.ts b/website/build.ts index a07770f0..f08b34ff 100644 --- a/website/build.ts +++ b/website/build.ts @@ -137,7 +137,7 @@ export const HRANESS_LOGO_URL = "https://hraness.com/icon.png" as const; export const HRANESS_LINKEDIN_URL = "https://www.linkedin.com/company/hraness" as const; export const NPM_PACKAGE_URL = "https://www.npmjs.com/package/@hraness/ghostget" as const; export const SKILL_REPOSITORY = "hraness/ghostget" as const; -export const CONTENT_REVIEWED_RELEASE = "v0.18.86" as const; +export const CONTENT_REVIEWED_RELEASE = "v0.18.87" as const; export const DEFAULT_POSTHOG_HOST = "https://us.i.posthog.com" as const; export const DEMO_PUBLIC_FILES = [ "wrench-first-capture.gif", From a409a88bed0b24ffa9863e68324ebb39dbe5f041 Mon Sep 17 00:00:00 2001 From: 0thernet <894119+0thernet@users.noreply.github.com> Date: Mon, 5 Oct 2026 19:18:12 -0400 Subject: [PATCH 2/2] Restore temporary-ref publication authority verification The publish job checks out the bare verified SHA, so its local ref inventory is empty and the helper runs before every write. Binding publication modes over verifyReleaseRefAuthority was wrong: it demands a pre-existing tag ref and leaves origin/main behind, so every call after the first fails preflight. Restore the publication verifier that imports governed refs under temporary ghostget-release names, proves them, and removes them, with the ported happy-path, advertisement drift, higher-tag and release-control drift regressions. --- scripts/release-ref-authority.test.ts | 118 ++++++++++++++++++ scripts/release-ref-authority.ts | 165 +++++++++++++++++++++----- 2 files changed, 255 insertions(+), 28 deletions(-) diff --git a/scripts/release-ref-authority.test.ts b/scripts/release-ref-authority.test.ts index 9144c3f1..7c7d4261 100644 --- a/scripts/release-ref-authority.test.ts +++ b/scripts/release-ref-authority.test.ts @@ -9,6 +9,7 @@ import { type GitCommandRunner, parseGovernedRemoteSnapshot, parseRemoteTagSnapshot, + verifyReleasePublicationAuthority, verifyReleaseRefAuthority, } from "./release-ref-authority"; @@ -505,3 +506,120 @@ describe("Ghostget release ref authority", () => { ); }); + +describe("Ghostget release publication authority", () => { + test("binds two combined advertisements immediately before publication and cleans up", () => { + const input = fixture(); + checkoutSha(input, input.releaseSha); + const { calls, runner } = runnerFor(input); + expect(verifyReleasePublicationAuthority({ + expectedMainSha: input.mainSha, + expectedReleaseSha: input.releaseSha, + phase: "prewrite", + requestedTag: "v1.0.0", + runner, + workingDirectory: input.work, + })).toEqual({ mainSha: input.mainSha, sha: input.releaseSha, tag: "v1.0.0" }); + expect(calls.filter((call) => call[0] === "ls-remote")).toEqual([ + ["ls-remote", "--sort=refname", "--refs", repositoryUrl, "refs/heads/main", "refs/tags/v*"], + ["ls-remote", "--sort=refname", "--refs", repositoryUrl, "refs/heads/main", "refs/tags/v*"], + ]); + const fetch = calls.find((call) => call[0] === "fetch") ?? []; + expect(fetch).toContain("--no-write-fetch-head"); + expect(fetch).toContain("refs/heads/main:refs/ghostget-release/publication-main"); + const diff = calls.find((call) => call[0] === "diff") ?? []; + expect(diff).toContain("refs/ghostget-release/publication-main"); + expect(text(input.work, ["for-each-ref", "--format=%(refname)"])).toBe(""); + + const second = runnerFor(input); + expect(verifyReleasePublicationAuthority({ + expectedMainSha: input.mainSha, + expectedReleaseSha: input.releaseSha, + phase: "postwrite", + requestedTag: "v1.0.0", + runner: second.runner, + workingDirectory: input.work, + })).toEqual({ mainSha: input.mainSha, sha: input.releaseSha, tag: "v1.0.0" }); + expect(text(input.work, ["for-each-ref", "--format=%(refname)"])).toBe(""); + }); + + test("rejects an annotated request and a changed terminal advertisement", () => { + const rejectedInput = fixture({ requestedTagKind: "annotated" }); + checkoutSha(rejectedInput, rejectedInput.releaseSha); + expect(() => verifyReleasePublicationAuthority({ + expectedMainSha: rejectedInput.mainSha, + expectedReleaseSha: rejectedInput.releaseSha, + phase: "prewrite", + requestedTag: "v1.0.0", + runner: runnerFor(rejectedInput).runner, + workingDirectory: rejectedInput.work, + })).toThrow(); + + const input = fixture(); + checkoutSha(input, input.releaseSha); + let reads = 0; + const drift = runnerFor(input, { + mutateResult: (arguments_, _invocation, result) => { + if (arguments_[0] === "ls-remote" && arguments_.at(-1) === "refs/tags/v*") { + reads += 1; + if (reads === 2) { + return Object.freeze({ + ...result, + stdout: new TextEncoder().encode( + new TextDecoder().decode(result.stdout).replace(input.mainSha, "8".repeat(40)), + ), + }); + } + } + return result; + }, + }); + expect(() => verifyReleasePublicationAuthority({ + expectedMainSha: input.mainSha, + expectedReleaseSha: input.releaseSha, + phase: "prewrite", + requestedTag: "v1.0.0", + runner: drift.runner, + workingDirectory: input.work, + })).toThrow("changed at the publication boundary"); + }); + + test("treats a higher raw tag as incomplete in both publication phases", () => { + const supersededRawTag = fixture({ higherTagKind: "lightweight" }); + checkoutSha(supersededRawTag, supersededRawTag.releaseSha); + for (const phase of ["prewrite", "postwrite"] as const) { + expect(verifyReleasePublicationAuthority({ + expectedMainSha: supersededRawTag.mainSha, + expectedReleaseSha: supersededRawTag.releaseSha, + phase, + requestedTag: "v1.0.0", + runner: runnerFor(supersededRawTag).runner, + workingDirectory: supersededRawTag.work, + })).toEqual({ + mainSha: supersededRawTag.mainSha, + sha: supersededRawTag.releaseSha, + tag: "v1.0.0", + }); + } + }); + + for (const phase of ["prewrite", "postwrite"] as const) { + test(`rejects release-control drift at ${phase}`, () => { + for (const driftOptions of [ + { workflowDrift: true }, + { releaseControlDrift: true }, + ] as const) { + const releaseControlDrift = fixture(driftOptions); + checkoutSha(releaseControlDrift, releaseControlDrift.releaseSha); + expect(() => verifyReleasePublicationAuthority({ + expectedMainSha: releaseControlDrift.mainSha, + expectedReleaseSha: releaseControlDrift.releaseSha, + phase, + requestedTag: "v1.0.0", + runner: runnerFor(releaseControlDrift).runner, + workingDirectory: releaseControlDrift.work, + })).toThrow("different release-control definitions"); + } + }); + } +}); diff --git a/scripts/release-ref-authority.ts b/scripts/release-ref-authority.ts index 6b14517b..9066d06a 100644 --- a/scripts/release-ref-authority.ts +++ b/scripts/release-ref-authority.ts @@ -8,6 +8,8 @@ const REPOSITORY_URL = `https://github.com/${REPOSITORY}.git`; const MAIN_BRANCH = "main"; const MAIN_REF = `refs/heads/${MAIN_BRANCH}`; const LOCAL_MAIN_REF = `refs/remotes/origin/${MAIN_BRANCH}`; +const PUBLICATION_MAIN_REF = "refs/ghostget-release/publication-main"; +const PUBLICATION_TAG_REF = "refs/ghostget-release/publication-tag"; const MAXIMUM_SNAPSHOT_BYTES = 64 * 1_024; const MAXIMUM_SNAPSHOT_ROWS = 500; const MAXIMUM_GIT_OUTPUT_BYTES = 256 * 1_024; @@ -70,6 +72,15 @@ export type ReleaseRefAuthorityInput = Readonly<{ workingDirectory?: string; }>; +export type ReleasePublicationAuthorityInput = Readonly<{ + expectedMainSha: string; + expectedReleaseSha: string; + phase: "prewrite" | "postwrite"; + requestedTag: string; + runner?: GitCommandRunner; + workingDirectory?: string; +}>; + function fail(message: string): never { throw new Error(message); } @@ -452,45 +463,143 @@ export function verifyReleaseRefAuthority(input: ReleaseRefAuthorityInput): Rele return Object.freeze({ mainSha: first.mainOid, sha: tag.objectName, tag: input.requestedTag }); } +// The publication job checks out the bare verified SHA, so its ref inventory +// is empty: authority imports the governed refs under temporary +// ghostget-release names, proves them, and removes them so the helper may run +// again before every later write. +export function verifyReleasePublicationAuthority( + input: ReleasePublicationAuthorityInput, +): ReleaseRefAuthority { + if (stableVersion(input.requestedTag) === undefined) { + fail("Publication authority requires one canonical stable version."); + } + if (!SHA.test(input.expectedReleaseSha)) { + fail("Publication authority requires one exact release commit."); + } + if (!SHA.test(input.expectedMainSha)) { + fail("Publication authority requires one authenticated main commit."); + } + const runner = input.runner ?? createDefaultGitRunner(resolve(input.workingDirectory ?? process.cwd())); + if (checkedHead(runner) !== input.expectedReleaseSha) { + fail("Publication helper is not executing from the exact verified release commit."); + } + const first = readReleaseSnapshot(runner, input.requestedTag); + if (first.mainOid !== input.expectedMainSha) { + fail("Combined advertisement does not match authenticated current main."); + } + if (first.requestedTagOid !== input.expectedReleaseSha) { + fail("Publication requires one direct lightweight release tag from the combined advertisement."); + } + + expectExactLocalRefs(runner, [], "Local publication-ref preflight"); + const fetchHead = removeStaleFetchHead(runner); + const shallow = decode(command( + runner, + ["rev-parse", "--is-shallow-repository"], + "Repository shallow-state check", + ), "Repository shallow-state check").trim(); + if (shallow !== "true" && shallow !== "false") fail("Repository shallow-state check was not exact."); + const requestedTagRef = `refs/tags/${input.requestedTag}`; + command( + runner, + [ + "fetch", + "--no-tags", + "--no-write-fetch-head", + "--no-recurse-submodules", + ...(shallow === "true" ? ["--unshallow"] : []), + REPOSITORY_URL, + `${MAIN_REF}:${PUBLICATION_MAIN_REF}`, + `${requestedTagRef}:${PUBLICATION_TAG_REF}`, + ], + "Exact publication authority import", + ); + if (existsSync(fetchHead)) fail("Exact publication authority import wrote forbidden FETCH_HEAD state."); + const refs = expectExactLocalRefs( + runner, + [PUBLICATION_MAIN_REF, PUBLICATION_TAG_REF], + "Local publication-ref import", + ); + const main = refs[0]; + const tag = refs[1]; + if ( + main === undefined + || main.objectType !== "commit" + || main.objectName !== first.mainOid + || main.peeledName !== "" + || main.peeledType !== "" + ) fail("Imported publication main is not the advertised commit."); + if ( + tag === undefined + || tag.objectType !== "commit" + || tag.objectName !== first.requestedTagOid + || tag.peeledName !== "" + || tag.peeledType !== "" + ) fail("Imported publication tag is not the advertised direct lightweight commit."); + if (runner(["merge-base", "--is-ancestor", input.expectedReleaseSha, PUBLICATION_MAIN_REF]).exitCode !== 0) { + fail("Verified release commit is not an ancestor of authenticated current main."); + } + requireUnchangedReleaseControls( + runner, + input.expectedReleaseSha, + PUBLICATION_MAIN_REF, + `Release commit and authenticated current main at ${input.phase}`, + ); + command( + runner, + ["update-ref", "-d", PUBLICATION_MAIN_REF, first.mainOid], + "Temporary publication main cleanup", + ); + command( + runner, + ["update-ref", "-d", PUBLICATION_TAG_REF, first.requestedTagOid], + "Temporary publication tag cleanup", + ); + expectExactLocalRefs(runner, [], "Local publication-ref cleanup"); + + const second = readReleaseSnapshot(runner, input.requestedTag); + if (second.canonical !== first.canonical) { + fail("Remote release-ref inventory changed at the publication boundary."); + } + return Object.freeze({ + mainSha: first.mainOid, + sha: first.requestedTagOid, + tag: input.requestedTag, + }); +} + function assertWorkflowIdentity(): void { if (process.env.GITHUB_REPOSITORY !== REPOSITORY || process.env.DEFAULT_BRANCH !== MAIN_BRANCH) { fail(`Release-ref authority must run for ${REPOSITORY} on exact default branch ${MAIN_BRANCH}.`); } } +const USAGE = "Usage: release-ref-authority.ts release TAG | " + + "publication-prewrite TAG EXPECTED_RELEASE_SHA EXPECTED_MAIN_SHA | " + + "publication-postwrite TAG EXPECTED_RELEASE_SHA EXPECTED_MAIN_SHA"; + function main(): void { assertWorkflowIdentity(); const [mode, first, second, third, ...extra] = process.argv.slice(2); - const publication = mode === "publication-prewrite" || mode === "publication-postwrite"; - if ( - extra.length > 0 - || first === undefined - || (mode === "release" && (second !== undefined || third !== undefined)) - || (mode !== "release" && !publication) - || (publication && (second === undefined || third === undefined)) - ) { - fail( - "Usage: release-ref-authority.ts release TAG | " - + "publication-prewrite TAG RELEASE_SHA MAIN_SHA | " - + "publication-postwrite TAG RELEASE_SHA MAIN_SHA", - ); + if (extra.length > 0 || mode === undefined || first === undefined) fail(USAGE); + if (mode === "release") { + if (second !== undefined || third !== undefined) fail(USAGE); + const authority = verifyReleaseRefAuthority({ requestedTag: first }); + process.stdout.write(`sha=${authority.sha}\ntag=${authority.tag}\nmain_sha=${authority.mainSha}\n`); + return; } - if (publication) { - if (second === undefined || !SHA.test(second)) { - fail("Publication authority requires one exact release commit."); - } - if (third === undefined || !SHA.test(third)) { - fail("Publication authority requires one authenticated main commit."); - } - } - const authority = verifyReleaseRefAuthority({ requestedTag: first }); - if (second !== undefined && authority.sha !== second) { - fail("Verified release commit does not match the expected release commit."); - } - if (third !== undefined && authority.mainSha !== third) { - fail("Verified advertised main does not match the authenticated current main."); + if (mode === "publication-prewrite" || mode === "publication-postwrite") { + if (second === undefined || third === undefined) fail(USAGE); + const authority = verifyReleasePublicationAuthority({ + expectedMainSha: third, + expectedReleaseSha: second, + phase: mode === "publication-prewrite" ? "prewrite" : "postwrite", + requestedTag: first, + }); + process.stdout.write(`sha=${authority.sha}\ntag=${authority.tag}\nmain_sha=${authority.mainSha}\n`); + return; } - process.stdout.write(`sha=${authority.sha}\ntag=${authority.tag}\nmain_sha=${authority.mainSha}\n`); + fail(USAGE); } if (process.argv[1] !== undefined && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) main();