diff --git a/.github/scripts/check_release_state.py b/.github/scripts/check_release_state.py index d81a35dc..5bea090d 100644 --- a/.github/scripts/check_release_state.py +++ b/.github/scripts/check_release_state.py @@ -38,7 +38,17 @@ END_MARKER = "" TOP_LEVEL_KEYS = {"schema_version", "as_of", "release", "closed_lanes", "blocked_lanes"} RELEASE_KEYS = { + # `version` is what is PUBLISHED. `activated` is what the tree is preparing. They differ + # for the whole window between a version bump and its publication, and collapsing them + # into one field is what deadlocked the v0.6.0 npm payload refresh against the Action + # contract: the Action's URL followed the published version while its digests followed + # the npm vendor manifest, which tracks the activated one. "version", + "activated", + # Digests of the PUBLISHED CLI archives and their inner binaries. The GitHub Action pins + # these. They belong here, next to the published version, rather than in the npm vendor + # manifest, which moves ahead of publication during a payload refresh. + "published_cli", "rust_crates", "python_package", "npm_package", @@ -47,6 +57,9 @@ "pdfium_environment", } PACKAGE_KEYS = {"name", "version"} +PUBLISHED_CLI_TARGETS = {"linux-x64", "macos-arm64"} +PUBLISHED_CLI_KEYS = {"archive", "archive_sha256", "binary_sha256"} +SHA256 = re.compile(r"[0-9a-f]{64}") GITHUB_RELEASE_KEYS = { "tag", "version", @@ -74,6 +87,11 @@ class ReleaseStateError(ValueError): """The release state or its generated documentation is invalid.""" +def _semver_tuple(value: str) -> tuple[int, int, int]: + major, minor, patch = value.split(".") + return (int(major), int(minor), int(patch)) + + def _exact_keys(value: object, expected: set[str], label: str) -> Mapping[str, object]: if not isinstance(value, dict) or set(value) != expected: raise ReleaseStateError(f"{label} must contain exactly {sorted(expected)}") @@ -153,6 +171,22 @@ def load_release_state(root: Path, path: Path) -> dict[str, object]: f"release.{field}.version must be a stable MAJOR.MINOR.PATCH version" ) + activated = _string(release["activated"], "release.activated") + if not SEMVER.fullmatch(activated): + raise ReleaseStateError("release.activated must be a semantic version") + if _semver_tuple(activated) < _semver_tuple(version): + raise ReleaseStateError("release.activated must not be behind release.version") + + published_cli = _exact_keys(release["published_cli"], PUBLISHED_CLI_TARGETS, "release.published_cli") + for target, entry in published_cli.items(): + fields = _exact_keys(entry, PUBLISHED_CLI_KEYS, f"release.published_cli.{target}") + for digest_field in ("archive_sha256", "binary_sha256"): + digest = _string(fields[digest_field], f"release.published_cli.{target}.{digest_field}") + if not SHA256.fullmatch(digest): + raise ReleaseStateError( + f"release.published_cli.{target}.{digest_field} must be lowercase 64-hex" + ) + github = _exact_keys(release["github_release"], GITHUB_RELEASE_KEYS, "release.github_release") if github["version"] != version or github["tag"] != f"v{version}": raise ReleaseStateError("GitHub release version and tag must match release.version") diff --git a/.github/scripts/test_release_state.py b/.github/scripts/test_release_state.py index f6e0c848..ef5b885c 100644 --- a/.github/scripts/test_release_state.py +++ b/.github/scripts/test_release_state.py @@ -60,6 +60,19 @@ def setUp(self) -> None: "as_of": "2026-07-02", "release": { "version": "0.3.0", + "activated": "0.3.0", + "published_cli": { + "linux-x64": { + "archive": "ethos-linux-x64.tar.gz", + "archive_sha256": "0" * 64, + "binary_sha256": "1" * 64, + }, + "macos-arm64": { + "archive": "ethos-macos-arm64.tar.gz", + "archive_sha256": "2" * 64, + "binary_sha256": "3" * 64, + }, + }, "rust_crates": ["ethos-doc-core", "ethos-verify", "ethos-pdf"], "python_package": {"name": "ethos-pdf", "version": "0.3.0"}, "npm_package": { diff --git a/CHANGELOG.md b/CHANGELOG.md index b91fe8e6..5cc2d4e3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,43 @@ ## Unreleased +### The ledger separates what is published from what is being prepared + +- boundary-exception: `docs/release-state.json` gains `release.activated` and + `release.published_cli`. `release.version` continues to mean the published version; + `activated` is what the tree is preparing. They differ for the whole window between a version + bump and its publication, and having one field carry both meanings is what deadlocked the + v0.6.0 npm payload refresh. + +- The deadlock, and its origin. The previous release-flow commit repointed + `actions/verify/tests/test_action.py` at `packages/npm/ethos-pdf/vendor/manifest.json`, + calling it "the record of the published CLI". It is not: a payload refresh moves that manifest + to the next release *before* that release is published. The Action's URL followed + `release.version` while its digests followed the manifest, so once the payload moved, the only + state satisfying both assertions was a v0.5.0 URL carrying v0.6.0 digests — an Action that + downloads one archive and checks it against another's digest, failing at install on every run. + `ci.yml`'s `released-cli-action-dogfood` executes the Action twice, so this broke for real + rather than only in an assertion. Reproduced before changing anything, and reproduced again as + passing afterwards with the full 0.6.0 manifest simulated. + +- The Action's digests now come from `release.published_cli`, alongside the version its URL + already used. Both halves derive from one published record and move together at publication. + +- `check_release_state.py` validates the new keys: `activated` must be a semantic version and + must not be behind `version`, and every `published_cli` digest must be lowercase 64-hex. + +### A vendored binary must report the version the manifest claims + +- `scripts/prepare-vendor.js` runs the binary with `--version` and requires + `ethos `. Every other check compared the manifest against itself: the + digests prove the manifest and the bytes agree with each other, never that the bytes are the + version claimed. That is the hole 1d23604 fell into — a package labelled 0.6.0 whose vendored + CLI reported `ethos 0.5.0`, with the full suite green in that state. Verified against the real + vendored 0.5.0 binary: a 0.6.0 manifest is now rejected. + +- `test/vendor-assembly.test.js` fixtures answer `--version` and carry `cli_version`, because a + fake that cannot report its version no longer models a real one. + - boundary-exception: `docs/validation/v0-6-0-release-promotion.md` records all six runbook promotion bindings. They were blank because `release.yml` had never completed a run; run 33325655578 on tag `v0.6.0` is the first green one, and the source commit, artifact names, diff --git a/actions/verify/tests/test_action.py b/actions/verify/tests/test_action.py index ce5e3ac8..9a1d9c55 100644 --- a/actions/verify/tests/test_action.py +++ b/actions/verify/tests/test_action.py @@ -32,15 +32,19 @@ ACTION = Path(__file__).resolve().parents[1] ROOT = ACTION.parents[1] FIXTURES = Path(__file__).resolve().parent / "fixtures" -# Derived, not transcribed. These were pinned to v0.4.0 while the version above came from +# Derived from the ledger's PUBLISHED release, not from the npm vendor manifest. +# +# These were once transcribed v0.4.0 literals while the URL version came from # docs/release-state.json, so the test contradicted itself and the Action shipped two releases -# behind. packages/npm/ethos-pdf/vendor/manifest.json is the record of the published CLI and is -# already boundary-gated, so the Action now follows it without a per-release edit here. -_VENDOR_MANIFEST = json.loads( - (ROOT / "packages/npm/ethos-pdf/vendor/manifest.json").read_text(encoding="utf-8") -) -_PUBLISHED_LINUX = _VENDOR_MANIFEST["targets"]["linux:x64"] -PUBLISHED_LINUX_ARCHIVE_SHA256 = _PUBLISHED_LINUX["release_asset_sha256"] +# behind. Sourcing them from packages/npm/ethos-pdf/vendor/manifest.json fixed that and +# introduced a worse fault: the vendor manifest tracks the ACTIVATED version, because a payload +# refresh moves it to the next release before that release is published. The URL then followed +# `version` while the digests followed the manifest, and the only state passing both assertions +# was a published-version URL carrying next-version digests — an Action that fails at install on +# every run. Both halves now come from the same published record and move together at publication. +_RELEASE_STATE = json.loads((ROOT / "docs/release-state.json").read_text(encoding="utf-8")) +_PUBLISHED_LINUX = _RELEASE_STATE["release"]["published_cli"]["linux-x64"] +PUBLISHED_LINUX_ARCHIVE_SHA256 = _PUBLISHED_LINUX["archive_sha256"] PUBLISHED_LINUX_BINARY_SHA256 = _PUBLISHED_LINUX["binary_sha256"] sys.path.insert(0, str(ACTION)) diff --git a/docs/release-state.json b/docs/release-state.json index 447b0ffc..ad6a8421 100644 --- a/docs/release-state.json +++ b/docs/release-state.json @@ -3,6 +3,7 @@ "as_of": "2026-07-21", "release": { "version": "0.5.0", + "activated": "0.6.0", "rust_crates": [ "ethos-doc-core", "ethos-verify", @@ -50,7 +51,19 @@ "ethos-package-ethos-verify-0.5.0", "ethos-package-ethos-pdf-0.5.0" ], - "pdfium_environment": "ETHOS_PDFIUM_LIBRARY_PATH" + "pdfium_environment": "ETHOS_PDFIUM_LIBRARY_PATH", + "published_cli": { + "linux-x64": { + "archive": "ethos-linux-x64.tar.gz", + "archive_sha256": "592b175c00d147625f2f2ccc8bc5c74fb8a00ee37f178c363757f2c72404876e", + "binary_sha256": "7b6b7cb03c1d16183b6cdd56f6d2ebe593a25ef257baa5b6553a0055c53e8f44" + }, + "macos-arm64": { + "archive": "ethos-macos-arm64.tar.gz", + "archive_sha256": "30fa34afda745d168e1af39a134e2281f4a409d425765f3dc85c2e312fcbbcc2", + "binary_sha256": "df2d46efb96501b8071cd8665ca525ee5af4787804cd04d07262354199ead913" + } + } }, "closed_lanes": { "rust_python_publication": "docs/validation/v0-5-0-release-closeout-summary.md", diff --git a/packages/npm/ethos-pdf/scripts/prepare-vendor.js b/packages/npm/ethos-pdf/scripts/prepare-vendor.js index c5f0227c..fc60bd5b 100644 --- a/packages/npm/ethos-pdf/scripts/prepare-vendor.js +++ b/packages/npm/ethos-pdf/scripts/prepare-vendor.js @@ -47,6 +47,30 @@ function verifyGroundingSupport(binaryPath) { return true; } +/** + * The manifest's cli_version must describe the bytes, not just sit beside them. + * + * Every other check here compares the manifest against itself: the digests prove the manifest + * and the binary agree with each other, never that the binary is the version claimed. That is + * exactly the hole 1d23604 fell into — a package labelled 0.6.0 whose vendored CLI reported + * ethos 0.5.0, with the full suite green in that state. Nothing else in the tree closes it. + */ +function verifyReportedVersion(binaryPath, cliVersion) { + const result = spawnSync(binaryPath, ["--version"], { + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"] + }); + const reported = `${result.stdout || ""}`.trim(); + const expected = `ethos ${cliVersion}`; + if (result.status !== 0 || reported !== expected) { + throw new Error( + `Binary reports ${JSON.stringify(reported)}, manifest cli_version requires ` + + `${JSON.stringify(expected)}: ${binaryPath}` + ); + } + return true; +} + function findEthosBinary(root) { const stack = [root]; while (stack.length > 0) { @@ -107,6 +131,7 @@ function prepareVendor({ const sourceBinary = findEthosBinary(tempDir); verifyBinaryChecksum(targetKey, target, sourceBinary); verifyGroundingSupport(sourceBinary); + verifyReportedVersion(sourceBinary, manifest.cli_version); const vendorBinary = path.join(vendorDir, target.binary); fs.copyFileSync(sourceBinary, vendorBinary); fs.chmodSync(vendorBinary, 0o755); diff --git a/packages/npm/ethos-pdf/test/vendor-assembly.test.js b/packages/npm/ethos-pdf/test/vendor-assembly.test.js index f273e5e7..ae5219b6 100644 --- a/packages/npm/ethos-pdf/test/vendor-assembly.test.js +++ b/packages/npm/ethos-pdf/test/vendor-assembly.test.js @@ -27,8 +27,15 @@ function writeFixtureArchive(root, assetName, nestedDir, binaryText) { return archive; } -const macBinary = '#!/usr/bin/env sh\nif [ "$1" = "--help" ]; then echo "Commands: grounding verify"; fi\n'; -const linuxBinary = '#!/usr/bin/env sh\nif [ "$1" = "--help" ]; then echo "Commands: grounding verify"; fi\n'; +const FIXTURE_CLI_VERSION = "0.5.0"; +// The fakes answer --version because prepare-vendor now binds the reported version to the +// manifest's cli_version. A fixture that cannot report its version would not model a real one. +const fakeBinary = + '#!/usr/bin/env sh\n' + + 'if [ "$1" = "--help" ]; then echo "Commands: grounding verify"; fi\n' + + 'if [ "$1" = "--version" ]; then echo "ethos ' + FIXTURE_CLI_VERSION + '"; fi\n'; +const macBinary = fakeBinary; +const linuxBinary = fakeBinary; const temp = fs.mkdtempSync(path.join(os.tmpdir(), "ethos-vendor-assembly-")); try { @@ -50,6 +57,7 @@ try { JSON.stringify( { version: 1, + cli_version: FIXTURE_CLI_VERSION, targets: { "darwin:arm64": { binary: "ethos-darwin-arm64",