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
34 changes: 34 additions & 0 deletions .github/scripts/check_release_state.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,17 @@
END_MARKER = "<!-- END GENERATED CURRENT RELEASE STATE -->"
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",
Expand All @@ -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",
Expand Down Expand Up @@ -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)}")
Expand Down Expand Up @@ -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")
Expand Down
13 changes: 13 additions & 0 deletions .github/scripts/test_release_state.py
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
37 changes: 37 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <manifest.cli_version>`. 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,
Expand Down
20 changes: 12 additions & 8 deletions actions/verify/tests/test_action.py
Original file line number Diff line number Diff line change
Expand Up @@ -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))

Expand Down
15 changes: 14 additions & 1 deletion docs/release-state.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
25 changes: 25 additions & 0 deletions packages/npm/ethos-pdf/scripts/prepare-vendor.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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);
Expand Down
12 changes: 10 additions & 2 deletions packages/npm/ethos-pdf/test/vendor-assembly.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -50,6 +57,7 @@ try {
JSON.stringify(
{
version: 1,
cli_version: FIXTURE_CLI_VERSION,
targets: {
"darwin:arm64": {
binary: "ethos-darwin-arm64",
Expand Down
Loading