diff --git a/docs/en/RELEASE-CANDIDATE-PRECHECK.md b/docs/en/RELEASE-CANDIDATE-PRECHECK.md index 757a675..971565a 100644 --- a/docs/en/RELEASE-CANDIDATE-PRECHECK.md +++ b/docs/en/RELEASE-CANDIDATE-PRECHECK.md @@ -11,9 +11,9 @@ release_candidate_precheck.py candidate --manifest PINS --pin NAME [--commit SHA release_candidate_precheck.py record --manifest PINS --pin NAME --record release/-vX.Y.Z.yaml ``` -**candidate** (before the release). The pinned commit exists and is on `devel`; the twelve files at it are byte-identical to the manifest; the manifest digest and the pin name recompute; the commit the release will actually run on (`--release-head`: the release branch head, or the merge commit on `main`) holds the same twelve files; the version is a valid next increment (the rule of `compiler.py`); the files in which `devel` has moved on since the pin are listed. +**candidate** (before the release). The pinned commit exists and is on `devel`; the twelve files at it are byte-identical to the manifest; the manifest digest and the pin name recompute; the commit the release will actually run on (`--release-head`: the release branch head, or the merge commit on `main`) holds the same twelve files; the version is a valid next increment (the rule of `compiler.py`); the files in which `devel` has moved on since the pin are listed. With `--release-head` it also reads `compiler_settings.release_artifacts.paths` from the `project.yaml` **at that head** (the declaration an envelope 3 record is written from): all twelve pinned paths must be declared, spelled exactly, or the run fails, because a record written from that head cannot carry the digest of a file that is not declared. A head with no declaration (a head of an earlier release) is not a failure; the record coverage then stays the open gate it was. -**record** (after the release). The record's `provenance.source_commit` holds the twelve files byte-identical to the manifest, **and** the record carries its own digest of every one of the twelve. The second is a release requirement (decision of 2026-11-01): `source_commit` identifies and retrieves the source state and stays mandatory, but it does not replace the file digests; the record has to carry the digest values and their algorithm itself, so that verifying the twelve files does not depend on a git object being reachable later. Where the record keeps them is the record contract, which is not decided here; the tool reads the places that exist (the schema sources in `specs`, the two files of `provenance.grammar_*`) and invents no field. +**record** (after the release). The record's `provenance.source_commit` holds the twelve files byte-identical to the manifest, **and** the record carries its own digest of every one of the twelve. The second is a release requirement (decision of 2026-11-01): `source_commit` identifies and retrieves the source state and stays mandatory, but it does not replace the file digests; the record has to carry the digest values and their algorithm itself, so that verifying the twelve files does not depend on a git object being reachable later. Where the record keeps them is the record contract (`RELEASE-RECORD-CONTRACT.md`): an **envelope 3** record keeps them in `artifacts.files`, as `sha256:` and 64 lowercase hex digits of the raw git blob bytes, and the tool reads only there. A record of an earlier envelope (1, 2) or with none keeps at most the schema sources in `specs` and the two files of `provenance.grammar_*`; those are read as before, and `artifacts` is not read for them. A digest in any other form (uppercase hex, no `sha256:` prefix, base64, a wrong length, a trailing newline, not a string), a path listed twice, an unknown `release.envelope` (including `3.0` and `true`), or an envelope 3 record with no `artifacts.files` list is a failure and never coverage. The tool does **not** check the artifacts block itself (order, uniqueness, `manifest_digest`, signatures): that is `tools/release_record_conformance.py`, and it says so on every envelope 3 run. Exit status 1 when a hard check fails. `OPEN` lines are gates the tool does not and cannot close; they never turn into a pass. @@ -22,9 +22,9 @@ Exit status 1 when a hard check fails. `OPEN` lines are gates the tool does not * `candidate` on `73ae469` with `--release-head 73ae469`: all checks pass, 12 of 12 files byte-identical. `devel` (`d27173f`) **has moved on in `proposals/atom-grammar/P0-4-MIGRATION.md`**, a pinned file: **a release cut from the head of `devel` is not the pin.** The release has to run on a branch cut from `73ae469`, or the pin has to move. * `candidate` with `--release-head` set to the head of `devel`: FAIL on that file. * `record` on `release/cic-primitives-v0.2.0.yaml`: FAIL. Its `source_commit` (`9c67ea4`) lacks 4 of the 12 files and holds 5 others with different bytes; the record's own digests of `check_grammar.py`, `instance-grammar.schema.yaml`, `policy-surface.yaml` and `access.yaml` do not equal the pinned files. This is the refutation of `v0.2.0` as the release of the pin, now by a repeatable tool. -* In `v0.2.0` the record digests 2 of the 12 files itself and equal (`role.yaml`, `shape.yaml`); 4 more are digested by name but with other bytes; 6 have no digest in it. With the present compiler a record can digest **at most 6 of the 12 by construction** (the schema sources and the two grammar files), so a new release cannot meet the 12-of-12 requirement until the record contract changes. That is why `candidate` keeps `record digest coverage` as an OPEN gate and `record` fails a record below 12 of 12. +* In `v0.2.0` the record digests 2 of the 12 files itself and equal (`role.yaml`, `shape.yaml`); 4 more are digested by name but with other bytes; 6 have no digest in it. A record of envelope 2 can digest **at most 6 of the 12 by construction** (the schema sources and the two grammar files); that is why `record` fails a record below 12 of 12, and why `candidate` kept `record digest coverage` as an OPEN gate. The envelope 3 contract, the compiler that writes it and the 30-path declaration (`RELEASE-RECORD-CONTRACT.md`, `RELEASE-COMPILER-ENVELOPE3.md`, `RELEASE-ARTIFACT-CLASSIFICATION.md`) remove that limit, and this tool now reads an envelope 3 record. * The negative evidence on `v0.2.0` is frozen and reproducible: `tests/fixtures/forms-pin-73ae469.yaml` is a copy of the consumer's pin, and `test_release_v020_does_not_hold_the_pinned_grammar` asserts the 4 absent files, the 5 files with other bytes, the 4 wrong record digests and the 2-of-12 coverage (it needs the full history and the tag `primitives/@v0.2.0`; in a shallow checkout it is skipped, not passed). Nothing about `v0.2.0` is corrected or hidden by this tool. - +* **2026-11-02, `v0.2.1`** (release commit `5e8c3219b52399e3e764b0b0bb27de710f5445a4`, source commit `106965b8cea7cce2081beb77008753af21e68b46`, cut from `73ae469` on `primitives/releases/v0.2.1`): `record` on `release/cic-primitives-v0.2.1.yaml` (SHA-256 `e5d8268a...cf63`): all 12 files byte-identical at `source_commit`, `files the record digests itself (12 of 12)`, `PASS record digest coverage`, `readiness: checks_passed_gates_open`, exit 0. The record layer, blobs layer and signatures of the same record were checked by `tools/release_record_conformance.py --require record,blobs,crypto` (exit 0) and `compiler.py verify-release`, not by this tool. `candidate` with `--release-head` set to the release commit reports the declaration PASS (12 pinned among 30 release artifacts); its `version` check fails there because the tag `primitives/@v0.2.1` exists, as it must after the release. This frozen record is `test_release_v021_holds_the_pinned_grammar_twelve_of_twelve` (commit id and record digest, not the tag; skipped without the full history, a failure where `CIC_REQUIRE_RELEASE_HISTORY=1`). ## Profile, readiness and exit status * **Profile.** `--profile cic-grammar-12` (the default) requires the pin set to be exactly the 12 distinct paths of the CIC grammar pin, fixed in the tool and never read from the manifest it judges; a dropped, added, swapped or repeated file fails, whatever the count. `--profile none` is the general tool and is a stated choice. A recomputed manifest digest shows consistency, not that the manifest comes from the entitled consumer; `manifest origin` is an OPEN gate. @@ -40,19 +40,19 @@ A `12/12 PASS` is **not** a statement that a release is genuine. It does not sho ## The candidate `73ae469` is not the head of `devel` -The candidate is the pinned commit `73ae469`. `devel` has moved on since (`d27173f`, and `P0-4-MIGRATION.md`, a pinned file, differs there). A pull request that targets `devel`, such as the one that adds this tool, gives no authority to cut the release from the current head of `devel`: a release cut there is not the pin, and `candidate --release-head ` fails on the changed file. The release runs on a branch cut from `73ae469`, or the pin moves; the former is the owner's chosen direction. +The candidate is the pinned commit `73ae469`. `devel` has moved on since (`d27173f`, and `P0-4-MIGRATION.md`, a pinned file, differs there). A pull request that targets `devel`, such as the one that adds this tool, gives no authority to cut the release from the current head of `devel`: a release cut there is not the pin, and `candidate --release-head ` fails on the changed file. The release runs on a branch cut from `73ae469`, or the pin moves; the former is the owner's chosen direction, and it is what `v0.2.1` was: a branch cut from `73ae469`, the six squash commits that carry the envelope 3 infrastructure cherry-picked onto it, and `devel` unchanged. ## Open gates (not checked by this tool) -0. **Record digest coverage:** the record carries its own digest of all 12 pinned files and the algorithm (see above); open until the record contract meets it. +0. **Record digest coverage** (`candidate` only): the head's declaration says what the compiler will digest; that the record carries its own digest of all 12 pinned files is shown by `record` mode on the record after the release, not before. Where the head declares nothing, the gate keeps its old reason (an earlier record digests at most 6 of the 12). 1. A CIC root pinned **outside** the record (`verify-release --trust-root`). 2. Signer authority: the release issuer certificate is not chained to anything in the record; whether the countersignature alone authorizes a release is not decided. 3. Certificate validity at the release date (the certificates of `v0.2.0` expire 2026-12-31). -4. The digest encoding: the record writes base64, a receipt takes `sha256:` and hex. The tool re-encodes for its own comparison only; the contract and Rust/Go golden tests are a Forms unit. +4. The digest encoding: an envelope 3 record writes `sha256:` (a contract on the record side; an earlier record wrote base64), a receipt takes `sha256:` and hex. Whether the receipt's own contract takes the record's values as they are, and the Rust/Go golden tests, are a Forms unit; the gate stays OPEN with the reworded reason. 5. Signing and publishing: on hold by the owner until the gates above are shown. ## Sequence this tool supports 1. Cut `primitives/releases/vX.Y.Z` from `73ae469` (not from the head of `devel`). Run `candidate` with `--release-head` on the head the release will run on, after the release preparation commit. Any pinned file in the difference is a stop. 2. Close the open gates. -3. After the release: `verify-release` with the pinned root, and `record` on the record. Only both together support a receipt that names the release. +3. After the release: `verify-release` with the pinned root, `release_record_conformance.py --repo --require record,blobs,crypto` on the record, and `record` on the record. Only these together support a receipt that names the release; none of them closes the open gates. diff --git a/docs/hu/RELEASE-CANDIDATE-PRECHECK.md b/docs/hu/RELEASE-CANDIDATE-PRECHECK.md index bdf46cf..d03af5f 100644 --- a/docs/hu/RELEASE-CANDIDATE-PRECHECK.md +++ b/docs/hu/RELEASE-CANDIDATE-PRECHECK.md @@ -6,8 +6,8 @@ A fogyasztó (`cic-primitive-forms`, `corpus/pins.yaml`) egy commitot és pontos ## Módok -* `candidate` (release előtt): a pinelt commit létezik és a `devel`-en van; a tizenkét fájl bájtra azonos a manifesttel; a manifest-digest és a pin neve újraszámolható; az a commit, amelyen a release ténylegesen fut (`--release-head`), ugyanazt a tizenkét fájlt tartalmazza; a verzió érvényes következő növelés; ki van listázva, mely fájlokban haladt tovább a `devel` a pin óta. -* `record` (release után): a rekord `provenance.source_commit`-ja bájtra azonos módon tartalmazza a tizenkét fájlt; **és** a rekord mind a tizenkét fájlról hordozza a saját digestjét. Ez release-követelmény (2026-11-01 döntés): a `source_commit` a forrásállapot azonosítására szolgál és kötelező marad, de nem helyettesíti a fájlszintű digesteket; a rekordnak a digestértékeket és az algoritmust is hordoznia kell, hogy a tizenkét fájl ellenőrzése ne függjön egy git-objektum későbbi elérhetőségétől. Hogy a rekord hol tárolja, az a rekord-szerződés dolga, itt nem dől el; az eszköz a meglévő helyeket olvassa, mezőt nem talál ki. +* `candidate` (release előtt): a pinelt commit létezik és a `devel`-en van; a tizenkét fájl bájtra azonos a manifesttel; a manifest-digest és a pin neve újraszámolható; az a commit, amelyen a release ténylegesen fut (`--release-head`), ugyanazt a tizenkét fájlt tartalmazza; a verzió érvényes következő növelés; ki van listázva, mely fájlokban haladt tovább a `devel` a pin óta. `--release-head` mellett az eszköz a **fej** `project.yaml`-jából a `compiler_settings.release_artifacts.paths` deklarációt is olvassa (ebből készül az Envelope 3 rekord): mind a tizenkét pinelt útvonalnak szerepelnie kell benne, pontosan úgy írva, különben a futás bukik, mert az onnan írt rekord nem hordozhatja egy nem deklarált fájl digestjét. Deklaráció nélküli fej (egy korábbi release feje) nem hiba; a rekord-lefedettség ilyenkor a régi nyitott kapu marad. +* `record` (release után): a rekord `provenance.source_commit`-ja bájtra azonos módon tartalmazza a tizenkét fájlt; **és** a rekord mind a tizenkét fájlról hordozza a saját digestjét. Ez release-követelmény (2026-11-01 döntés): a `source_commit` a forrásállapot azonosítására szolgál és kötelező marad, de nem helyettesíti a fájlszintű digesteket; a rekordnak a digestértékeket és az algoritmust is hordoznia kell, hogy a tizenkét fájl ellenőrzése ne függjön egy git-objektum későbbi elérhetőségétől. Hogy a rekord hol tárolja, azt a rekord-szerződés mondja meg: az **Envelope 3** rekord az `artifacts.files`-ban, `sha256:` és 64 kisbetűs hex számjegy formában (a nyers git-blob bájtok digestje), és az eszköz csak ott olvassa. A korábbi envelope-ú (1, 2) vagy envelope nélküli rekord legfeljebb a `specs` séma-forrásait és a két `provenance.grammar_*` fájlt hordozza; ezeket az eszköz a régi módon olvassa, az `artifacts`-ot náluk nem. Más alakú digest (nagybetűs hex, `sha256:` előtag nélkül, base64, rossz hossz, záró újsor, nem szöveg), kétszer szereplő útvonal, ismeretlen `release.envelope` (a `3.0` és a `true` is) vagy `artifacts.files` lista nélküli Envelope 3 rekord hiba, sosem lefedettség. Az eszköz az artifacts blokk önmagát **nem** ellenőrzi (sorrend, egyediség, `manifest_digest`, aláírások): az a `tools/release_record_conformance.py` dolga, és minden Envelope 3 futáson ezt ki is írja. Az 1-es kilépési kód kemény hibát jelent. Az `OPEN` sorok olyan kapuk, amelyeket az eszköz nem zár le, és sosem válnak PASS-szá. @@ -15,13 +15,14 @@ Az 1-es kilépési kód kemény hibát jelent. Az `OPEN` sorok olyan kapuk, amel * `candidate` a `73ae469`-en: minden ellenőrzés zöld, 12/12 fájl azonos. A `devel` (`d27173f`) **továbbhaladt a `P0-4-MIGRATION.md`-ben**, ami pinelt fájl: **a `devel` fejéről vágott release nem a pin.** A release-nek a `73ae469`-ből vágott ágon kell futnia, vagy a pint kell mozgatni. * `record` a `v0.2.0` rekordon: FAIL (4 fájl hiányzik, 5 eltér a source commitban; a rekord négy saját digestje sem egyezik a pinelttel). -* A mostani compilerrel a rekord **legfeljebb 6 fájlt digesteltethet a 12-ből** (a séma-források és a két grammar-fájl), ezért új release sem teljesítheti a 12/12 követelményt a rekord-szerződés változása nélkül. A `candidate` ezt nyitott kapuként tartja, a `record` 12/12 alatt hibát jelez. A `v0.2.0` negatív bizonyítéka befagyasztott fixture-rel reprodukálható, az eszköz semmit nem javít ki és nem fed el. +* Egy 2-es envelope-ú rekord **legfeljebb 6 fájlt digesteltethet a 12-ből** (a séma-források és a két grammar-fájl); ezért bukik a `record` 12/12 alatt, és ezért tartotta a `candidate` nyitott kapuként a lefedettséget. Az Envelope 3 szerződés, az azt író compiler és a 30 útvonalas deklaráció ezt a korlátot megszünteti, az eszköz most már olvassa az Envelope 3 rekordot. A `v0.2.0` negatív bizonyítéka befagyasztott fixture-rel reprodukálható, az eszköz semmit nem javít ki és nem fed el. +* **2026-11-02, `v0.2.1`** (release commit `5e8c3219b52399e3e764b0b0bb27de710f5445a4`, source commit `106965b8cea7cce2081beb77008753af21e68b46`, a `73ae469`-ből vágva a `primitives/releases/v0.2.1` ágon): a `record` a `release/cic-primitives-v0.2.1.yaml`-on: mind a 12 fájl bájtra azonos a `source_commit`-on, `12 of 12` saját digest, `PASS record digest coverage`, `readiness: checks_passed_gates_open`, kilépési kód 0. A rekord-, blobs- és aláírás-rétegét ugyanezen a rekordon a `release_record_conformance.py --require record,blobs,crypto` és a `compiler.py verify-release` ellenőrizte, nem ez az eszköz. A befagyasztott bizonyíték: `test_release_v021_holds_the_pinned_grammar_twelve_of_twelve` (commit-azonosító és rekord-digest, nem a tag; teljes történet nélkül kimarad, ahol `CIC_REQUIRE_RELEASE_HISTORY=1`, ott hiba). * A `12/12 PASS` **nem** állítja, hogy a release hiteles: az aláíró jogosultságát, a trust anchort és a tanúsítvány-érvényességet nem bizonyítja (`OPEN` sorok, minden futás `scope` sorral zár). * A `73ae469` jelölt nem a `devel` jelenlegi feje. Egy `devel`-re célzó PR (mint ez) nem ad felhatalmazást a release `devel` fejéből való vágására. ## Nyitott kapuk (az eszköz nem ellenőrzi) -1. A rekordon **kívül** pinelt CIC root (`--trust-root`). 2. Az aláíró jogosultsága (az issuer tanúsítvány nincs láncolva; az ellenjegyzés önmagában felhatalmaz-e, nincs eldöntve). 3. A tanúsítványok érvényessége a release napján (a `v0.2.0` tanúsítványai 2026-12-31-én lejárnak). 4. A digest-kódolás szerződése (base64 és `sha256:` hex; Forms-egység Rust/Go golden tesztekkel). 5. Aláírás és publikálás: a tulajdonos HOLD-ja alatt. +1. A rekordon **kívül** pinelt CIC root (`--trust-root`). 2. Az aláíró jogosultsága (az issuer tanúsítvány nincs láncolva; az ellenjegyzés önmagában felhatalmaz-e, nincs eldöntve). 3. A tanúsítványok érvényessége a release napján (a `v0.2.0` tanúsítványai 2026-12-31-én lejárnak). 4. A digest-kódolás: az Envelope 3 rekord `sha256:`-et ír (a rekord oldalán ez szerződés; a korábbi rekord base64-et írt), a receipt `sha256:` és hex formát vár. Hogy a receipt saját szerződése változtatás nélkül átveszi-e a rekord értékeit, és a Rust/Go golden tesztek, Forms-egység; a kapu OPEN marad, átfogalmazott indokkal. 5. Aláírás és publikálás: a tulajdonos HOLD-ja alatt. ## Profil, readiness, kilépési kód diff --git a/tests/test_release_candidate_precheck_envelope3.py b/tests/test_release_candidate_precheck_envelope3.py new file mode 100644 index 0000000..85b04a4 --- /dev/null +++ b/tests/test_release_candidate_precheck_envelope3.py @@ -0,0 +1,365 @@ +"""Tests of tools/release_candidate_precheck.py for envelope 3 records and for the release head's artifact declaration. + +Each test breaks ONE thing in a small git repository and reads the message, not the exit status alone. The expected values are computed here with hashlib and plain git, not +with the tool. The tool is loaded through `load_tool`, which the planted-defect runs (test_release_candidate_precheck_envelope3_planted.py) point at a mutated copy. +""" +import base64 +import hashlib +import importlib.util +import io +import os +import pathlib +import subprocess +from contextlib import redirect_stdout + +import pytest +import yaml + +REPO = pathlib.Path(__file__).resolve().parents[1] +TOOL = REPO / "tools" / "release_candidate_precheck.py" + + +def load_tool(): + spec = importlib.util.spec_from_file_location("precheck_under_test", os.environ.get("PRECHECK_TOOL") or str(TOOL)) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +ENV = {"GIT_AUTHOR_NAME": "t", "GIT_AUTHOR_EMAIL": "t@t", "GIT_COMMITTER_NAME": "t", "GIT_COMMITTER_EMAIL": "t@t", "GIT_CONFIG_GLOBAL": "/dev/null", "GIT_CONFIG_SYSTEM": "/dev/null", + "PATH": "/usr/bin:/bin:/usr/local/bin"} +FILES = { + "LICENSE.md": "licence\n", + "proposals/atom-grammar/check_grammar.py": "print('grammar')\n", + "proposals/atom-grammar/instance-grammar.schema.yaml": "type: object\n", + "schemas/atomic/role.yaml": "role: 1\n", + "é.yaml": "unicode path\n", +} +PATHS = sorted(FILES, key=lambda p: p.encode("utf-8")) + + +def git(repo, *args): + r = subprocess.run(["git", "-C", str(repo), *args], capture_output=True, text=True, env=ENV, check=False) + assert r.returncode == 0, r.stderr + return r.stdout.strip() + + +def commit_files(repo, files, msg): + for path, text in files.items(): + p = pathlib.Path(repo) / path + p.parent.mkdir(parents=True, exist_ok=True) + p.write_text(text, encoding="utf-8") + git(repo, "add", "-A") + git(repo, "commit", "-q", "-m", msg) + return git(repo, "rev-parse", "HEAD") + + +def hexdigest(text): + return hashlib.sha256(text.encode("utf-8")).hexdigest() + + +def project_yaml(paths): + return yaml.safe_dump({"project": {"name": "x"}, "compiler_settings": {"release_artifacts": {"paths": list(paths)}}}) + + +def make_world(tmp_path, head_files=None): + """A repository whose first commit holds the pinned FILES, and a second commit (the release head) that adds project.yaml declaring `PATHS` (or `head_files`).""" + repo = tmp_path / "repo" + repo.mkdir() + git(repo, "init", "-q", "-b", "devel") + pinned = commit_files(repo, FILES, "pinned") + head = commit_files(repo, {"project.yaml": project_yaml(PATHS)} if head_files is None else head_files, "release head") + manifest_text = "".join(f"{hexdigest(FILES[p])} {p}\n" for p in PATHS) + digest = hashlib.sha256(manifest_text.encode("utf-8")).hexdigest() + name = f"cic-primitives-{pinned[:7]}-{digest[:8]}" + pins = {"grammar": {name: {"repo": "x/y", "ref": "devel", "commit": pinned, + "files": [{"path": p, "sha256": hexdigest(FILES[p])} for p in PATHS], "manifest_sha256": digest}}} + manifest = tmp_path / "pins.yaml" + manifest.write_text(yaml.safe_dump(pins, allow_unicode=True), encoding="utf-8") + return {"repo": repo, "pinned": pinned, "head": head, "name": name, "manifest": manifest, "tmp": tmp_path} + + +@pytest.fixture +def world(tmp_path): + return make_world(tmp_path) + + +def run(world, mode, **kw): + tool = load_tool() + argv = [mode, "--repo", str(world["repo"]), "--manifest", str(world["manifest"]), "--pin", world["name"], "--devel", "devel", "--profile", "none"] + for k, v in kw.items(): + argv += [f"--{k.replace('_', '-')}", str(v)] + out = io.StringIO() + with redirect_stdout(out): + rc = tool.main(argv) + return rc, out.getvalue() + + +def artifact_files(overrides=None, drop=(), extra=()): + """The artifacts.files of an envelope 3 record that digests every pinned file correctly, with the given changes.""" + files = {p: "sha256:" + hexdigest(FILES[p]) for p in PATHS if p not in drop} + files.update(overrides or {}) + entries = [{"path": p, "digest": d} for p, d in files.items()] + return entries + list(extra) + + +def make_record(world, source=None, envelope=3, artifacts="default", legacy_digests=False, **top): + """An envelope 3 record by default. `legacy_digests` fills the places of the earlier envelopes (spec meta_hash, provenance) with correct base64 digests.""" + def b64(text): + return base64.b64encode(hashlib.sha256(text.encode("utf-8")).digest()).decode() + prov = {"source_commit": source or world["pinned"]} + specs = [] + if legacy_digests: + prov.update(grammar_sha256=b64(FILES["proposals/atom-grammar/check_grammar.py"]), grammar_schema_sha256=b64(FILES["proposals/atom-grammar/instance-grammar.schema.yaml"])) + specs = [{"id": p, "source_path": p, "meta_hash": b64(FILES[p])} for p in FILES if not p.startswith("proposals/atom-grammar/check_grammar") and not p.endswith("instance-grammar.schema.yaml")] + rec = {"kind": "PrimitiveRelease", "version": "0.9.0", "provenance": prov, "specs": specs} + if envelope is not None: + rec["release"] = {"envelope": envelope} + if artifacts == "default": + artifacts = {"manifest_digest": "sha256:" + "0" * 64, "files": artifact_files()} + if artifacts is not None: + rec["artifacts"] = artifacts + rec.update(top) + p = world["tmp"] / "record.yaml" + p.write_text(yaml.safe_dump(rec, allow_unicode=True), encoding="utf-8") + return p + + +# ── record mode, envelope 3 ────────────────────────────────────────────────────────────────────── + +def test_an_envelope_3_record_that_digests_every_pinned_file_passes_and_leaves_the_gates_open(world): + rc, out = run(world, "record", record=make_record(world)) + assert rc == 0, out + assert "PASS source_commit: all 5 files are byte-identical" in out + assert "INFO record read: envelope 3: artifacts.files only" in out and "tools/release_record_conformance.py" in out + assert "files the record digests itself (5 of 5)" in out + assert "PASS record digest coverage: all 5 pinned files have a digest of their own in the record, and each equals the pinned file" in out + for gate in ("manifest origin", "trust anchor", "signer authority", "certificate validity", "digest encoding", "signing and publishing"): + assert f"OPEN {gate}" in out + assert "OPEN digest encoding: the record side is a contract" in out + assert "readiness: checks_passed_gates_open" in out and "readiness: ready" not in out + + +def test_a_pinned_file_missing_from_the_artifacts_fails_even_when_the_commit_holds_it(world): + rc, out = run(world, "record", record=make_record(world, artifacts={"files": artifact_files(drop=["schemas/atomic/role.yaml"])})) + assert rc == 1 + assert "PASS source_commit: all 5 files are byte-identical" in out + assert "FAIL record digest coverage: 4 of 5 present and equal: 1 have no digest of their own in the record and rest on the source commit alone: schemas/atomic/role.yaml" in out + assert "PASS record digest coverage" not in out + + +def test_an_artifact_digest_that_is_not_the_pinned_file_fails(world): + other = "sha256:" + hexdigest("not the file\n") + rc, out = run(world, "record", record=make_record(world, artifacts={"files": artifact_files({"schemas/atomic/role.yaml": other})})) + assert rc == 1 + assert "FAIL record digest of schemas/atomic/role.yaml: artifacts.files does not equal the pinned file" in out + assert "FAIL record digest coverage: 4 of 5 present and equal" in out and "have a digest that is not the pinned file's" in out + assert "PASS record digest coverage" not in out + + +@pytest.mark.parametrize("bad", [ + pytest.param(lambda h: "sha256:" + h.upper(), id="uppercase hex"), + pytest.param(lambda h: h, id="no algorithm prefix"), + pytest.param(lambda h: "sha512:" + h, id="another algorithm"), + pytest.param(lambda h: "sha256:" + h[:-1], id="63 hex digits"), + pytest.param(lambda h: "sha256:" + h + "0", id="65 hex digits"), + pytest.param(lambda h: base64.b64encode(bytes.fromhex(h)).decode(), id="base64 of the digest"), + pytest.param(lambda h: "sha256:" + h + "\n", id="trailing newline"), + pytest.param(lambda h: None, id="no digest"), + pytest.param(lambda h: 12345, id="not a string"), +]) +def test_a_digest_that_is_not_the_contract_form_is_not_coverage(world, bad): + path = "schemas/atomic/role.yaml" + rc, out = run(world, "record", record=make_record(world, artifacts={"files": artifact_files({path: bad(hexdigest(FILES[path]))})})) + assert rc == 1, out + assert f"FAIL record digest of {path}: artifacts.files does not equal the pinned file" in out + assert "PASS record digest coverage" not in out + + +def test_a_path_listed_twice_is_not_coverage_even_when_both_digests_are_right(world): + path = "schemas/atomic/role.yaml" + twice = {"files": artifact_files(extra=[{"path": path, "digest": "sha256:" + hexdigest(FILES[path])}])} + rc, out = run(world, "record", record=make_record(world, artifacts=twice)) + assert rc == 1, out + assert f"FAIL record digest of {path}" in out + assert "PASS record digest coverage" not in out + + +def test_an_envelope_3_record_reads_the_artifacts_only_not_the_places_of_the_earlier_envelopes(world): + # every legacy place (spec meta_hash, provenance digests) is right, the artifacts block is empty: that is not coverage for envelope 3 + rc, out = run(world, "record", record=make_record(world, legacy_digests=True, artifacts={"files": []})) + assert rc == 1, out + assert "FAIL record digest coverage: 0 of 5 present and equal" in out + + +@pytest.mark.parametrize("envelope", [None, 1, 2]) +def test_an_earlier_envelope_reads_its_own_places_and_not_the_artifacts(world, envelope): + # the artifacts block is complete and right, the legacy places are empty: for envelope 1 and 2 (and a record with no envelope) that is not coverage + rc, out = run(world, "record", record=make_record(world, envelope=envelope)) + assert rc == 1, out + assert "FAIL record digest coverage: 0 of 5 present and equal" in out + assert "record read: envelope 3" not in out + # ... and with its own places filled it passes, as it always did + rc, out = run(world, "record", record=make_record(world, envelope=envelope, legacy_digests=True, artifacts=None)) + assert rc == 0, out + assert "PASS record digest coverage: all 5 pinned files" in out + assert "OPEN digest encoding: the base64 -> sha256:hex mapping" in out + + +@pytest.mark.parametrize("artifacts", [None, "files", {"manifest_digest": "x"}, {"files": "x"}, {"files": {"a": 1}}]) +def test_an_envelope_3_record_without_an_artifacts_list_fails(world, artifacts): + rc, out = run(world, "record", record=make_record(world, artifacts=artifacts)) + assert rc == 1, out + assert "FAIL record digest coverage: an envelope 3 record carries no artifacts.files list" in out + + +def test_artifact_entries_that_are_not_mappings_with_a_path_are_ignored_not_a_crash(world): + junk = {"files": artifact_files(extra=[None, "x", {"digest": "sha256:" + "0" * 64}, {"path": 5, "digest": "x"}])} + rc, out = run(world, "record", record=make_record(world, artifacts=junk)) + assert rc == 0, out + + +@pytest.mark.parametrize("envelope", [4, 0, "3", 3.0, True, [3]]) +def test_an_envelope_this_tool_does_not_read_fails(world, envelope): + rc, out = run(world, "record", record=make_record(world, envelope=envelope)) + assert rc == 1, out + assert "FAIL record envelope: release.envelope is " in out + assert "PASS record digest coverage" not in out + + +def test_the_source_commit_stays_mandatory_when_the_artifacts_are_complete(world): + # a source commit that lacks a pinned file: the artifact digests are right, the record is still refused + repo = world["repo"] + bare = commit_files(repo, {"unrelated": "x\n"}, "unrelated") + git(repo, "checkout", "-q", "-b", "other", world["pinned"]) + (repo / "schemas/atomic/role.yaml").unlink() + git(repo, "add", "-A") + git(repo, "commit", "-q", "-m", "drops a pinned file") + without = git(repo, "rev-parse", "HEAD") + assert bare != without + rc, out = run(world, "record", record=make_record(world, source=without)) + assert rc == 1, out + assert "FAIL source_commit: schemas/atomic/role.yaml: absent at the commit" in out + rc, out = run(world, "record", record=make_record(world, provenance={"source_commit": None})) + assert rc == 1 and "FAIL source_commit: the record carries none" in out + + +# ── candidate mode: the release head's declaration ─────────────────────────────────────────────── + +def test_a_release_head_that_declares_every_pinned_file_passes_and_the_record_gate_stays_open(world): + rc, out = run(world, "candidate", release_head=world["head"], version="0.1.0") + assert rc == 0, out + assert "PASS release artifact declaration: the release head declares all 5 pinned files among its 5 release artifacts" in out + assert "OPEN record digest coverage: the declaration only says what the compiler will digest" in out + assert "PASS record digest coverage" not in out + assert "at most 6" not in out + assert "OPEN digest encoding: the record side is a contract" in out + for gate in ("trust anchor", "signer authority", "certificate validity", "signing and publishing"): + assert f"OPEN {gate}" in out + assert "readiness: checks_passed_gates_open" in out + + +def test_a_declaration_with_more_paths_than_the_pin_is_fine(tmp_path): + w = make_world(tmp_path, {"project.yaml": project_yaml(PATHS + ["docs/a.md", "docs/b.md"])}) + rc, out = run(w, "candidate", release_head=w["head"]) + assert rc == 0, out + assert "among its 7 release artifacts" in out + + +def test_a_declaration_that_leaves_a_pinned_file_out_fails(tmp_path): + w = make_world(tmp_path, {"project.yaml": project_yaml([p for p in PATHS if p != "schemas/atomic/role.yaml"])}) + rc, out = run(w, "candidate", release_head=w["head"]) + assert rc == 1 + assert "FAIL release artifact declaration: the release head does not declare 1 of the 5 pinned files as release artifacts" in out and "schemas/atomic/role.yaml" in out + assert "PASS release artifact declaration" not in out + assert "OPEN record digest coverage: the record has to carry its own digest of all 12 pinned files" in out + + +def test_a_path_spelled_differently_is_not_the_pinned_path(tmp_path): + paths = ["./" + p if p == "LICENSE.md" else p for p in PATHS] + w = make_world(tmp_path, {"project.yaml": project_yaml(paths)}) + rc, out = run(w, "candidate", release_head=w["head"]) + assert rc == 1 and "FAIL release artifact declaration" in out and "LICENSE.md" in out + + +@pytest.mark.parametrize("project, kind", [ + pytest.param({"other": "x\n"}, "no project.yaml", id="no project.yaml"), + pytest.param({"project.yaml": yaml.safe_dump({"compiler_settings": {"vault_key_name": "k"}})}, "none at the release head", id="no declaration"), + pytest.param({"project.yaml": yaml.safe_dump({})}, "none at the release head", id="empty project.yaml"), +]) +def test_a_head_without_a_declaration_keeps_the_old_open_gate_and_is_not_a_failure(tmp_path, project, kind): + w = make_world(tmp_path, project) + rc, out = run(w, "candidate", release_head=w["head"]) + assert rc == 0, out + assert kind in out and "INFO release artifact declaration" in out + assert "OPEN record digest coverage: the record has to carry its own digest of all 12 pinned files" in out and "at most 6 of the 12" in out + assert "PASS release artifact declaration" not in out + assert "OPEN digest encoding: the base64 -> sha256:hex mapping" in out + + +@pytest.mark.parametrize("text", [ + pytest.param(yaml.safe_dump({"compiler_settings": {"release_artifacts": {"paths": "LICENSE.md"}}}), id="paths is a string"), + pytest.param(yaml.safe_dump({"compiler_settings": {"release_artifacts": {"paths": [1, 2]}}}), id="paths are not strings"), + pytest.param(yaml.safe_dump({"compiler_settings": {"release_artifacts": {}}}), id="no paths key"), + pytest.param(yaml.safe_dump({"compiler_settings": {"release_artifacts": ["LICENSE.md"]}}), id="a list instead of a mapping"), + pytest.param("compiler_settings: [unclosed\n", id="does not parse"), +]) +def test_a_declaration_that_is_not_a_list_of_paths_fails(tmp_path, text): + w = make_world(tmp_path, {"project.yaml": text}) + rc, out = run(w, "candidate", release_head=w["head"]) + assert rc == 1, out + assert "FAIL release artifact declaration" in out + assert "PASS release artifact declaration" not in out + + +def test_without_a_release_head_the_declaration_is_not_read_and_the_old_gate_stays(world): + rc, out = run(world, "candidate") + assert rc == 0, out + assert "release artifact declaration" not in out + assert "OPEN release head: not given" in out + assert "OPEN record digest coverage: the record has to carry its own digest of all 12 pinned files" in out + + +# ── the release v0.2.1 record, frozen ──────────────────────────────────────────────────────────── + +# Immutable identifiers of the evidence, like the v0.2.0 ones: the record is read from the release COMMIT and its bytes are pinned by their SHA-256. A tag is a mutable +# reference and is not the evidence. +V021_SOURCE = "106965b8cea7cce2081beb77008753af21e68b46" +V021_RELEASE_COMMIT = "5e8c3219b52399e3e764b0b0bb27de710f5445a4" +V021_RECORD_SHA256 = "e5d8268aede0d24f02e60a151099a2fdc7615172ed3105b0a05b6b5d19addf63" +V021_PIN = "cic-primitives-73ae469-6a188078" + + +def has_commit(commit): + return subprocess.run(["git", "-C", str(REPO), "cat-file", "-e", f"{commit}^{{commit}}"], capture_output=True, check=False).returncode == 0 + + +COMMITS = has_commit(V021_SOURCE) and has_commit(V021_RELEASE_COMMIT) +REQUIRED = os.environ.get("CIC_REQUIRE_RELEASE_HISTORY") == "1" + + +def test_the_history_of_release_v021_is_there_where_it_is_required(): + if not REQUIRED: + return + assert COMMITS, "CIC_REQUIRE_RELEASE_HISTORY=1 but the commits of release v0.2.1 are not in this clone (fetch-depth: 0)" + + +@pytest.mark.skipif(not COMMITS and not REQUIRED, reason="the history of release v0.2.1 is not in this clone; run with the full history (the gate does)") +def test_release_v021_holds_the_pinned_grammar_twelve_of_twelve(tmp_path): + shown = subprocess.run(["git", "-C", str(REPO), "show", f"{V021_RELEASE_COMMIT}:release/cic-primitives-v0.2.1.yaml"], capture_output=True, check=False) + assert shown.returncode == 0 + assert hashlib.sha256(shown.stdout).hexdigest() == V021_RECORD_SHA256, "the record of v0.2.1 is not the frozen one" + record_path = tmp_path / "cic-primitives-v0.2.1.yaml" + record_path.write_bytes(shown.stdout) + tool = load_tool() + out = io.StringIO() + with redirect_stdout(out): + rc = tool.main(["record", "--repo", str(REPO), "--manifest", str(REPO / "tests/fixtures/forms-pin-73ae469.yaml"), "--pin", V021_PIN, "--record", str(record_path)]) # the real 12-file profile + text = out.getvalue() + assert rc == 0, text + assert "PASS source_commit: all 12 files are byte-identical to the manifest" in text + assert "files the record digests itself (12 of 12)" in text + assert "PASS record digest coverage: all 12 pinned files have a digest of their own in the record, and each equals the pinned file" in text + for gate in ("trust anchor", "signer authority", "certificate validity", "signing and publishing"): + assert f"OPEN {gate}" in text + assert "readiness: checks_passed_gates_open" in text diff --git a/tests/test_release_candidate_precheck_envelope3_planted.py b/tests/test_release_candidate_precheck_envelope3_planted.py new file mode 100644 index 0000000..0f1e457 --- /dev/null +++ b/tests/test_release_candidate_precheck_envelope3_planted.py @@ -0,0 +1,64 @@ +"""Planted-defect proof of tests/test_release_candidate_precheck_envelope3.py: a test that cannot fail proves nothing. + +Each mutant is a copy of tools/release_candidate_precheck.py with ONE defect planted (a check weakened, a branch crossed, a gate turned into a pass). The envelope 3 tests are +run against it in a subprocess (PRECHECK_TOOL) and MUST fail. Every mutant is compiled first, so a mutant that dies of a syntax error is not counted as killed. The unmutated copy +is run too and must pass, so that a failure is the defect and not the harness. +""" +import pathlib +import subprocess +import sys + +import pytest + +REPO = pathlib.Path(__file__).resolve().parents[1] +TOOL = REPO / "tools" / "release_candidate_precheck.py" +SUITE = REPO / "tests" / "test_release_candidate_precheck_envelope3.py" + +# (name, [(old, new), ...]): every `old` occurs exactly once in the tool +MUTANTS = [ + ("uppercase hex accepted", + [('ARTIFACT_DIGEST = re.compile(r"sha256:([0-9a-f]{64})")', 'ARTIFACT_DIGEST = re.compile(r"sha256:([0-9a-fA-F]{64})")'), ("return m.group(1)", "return m.group(1).lower()")]), + ("algorithm prefix optional", [('ARTIFACT_DIGEST = re.compile(r"sha256:([0-9a-f]{64})")', 'ARTIFACT_DIGEST = re.compile(r"(?:sha256:)?([0-9a-f]{64})")')]), + ("prefix match instead of the whole string", [("ARTIFACT_DIGEST.fullmatch(value)", "ARTIFACT_DIGEST.match(value)")]), + ("a path listed twice takes its first digest", [("digests[0] if len(digests) == 1 else None", "digests[0]")]), + ("envelope 3 reads the places of the earlier envelopes", [("digested = artifact_digests(report, rec)\n if digested is None:", "digested = legacy_digests(rec, prov)\n if digested is None:")]), + ("an earlier envelope also reads the artifacts", [("digested = legacy_digests(rec, prov)\n else:", "digested = artifact_digests(report, rec) or legacy_digests(rec, prov)\n else:")]), + ("an unknown envelope is read as an earlier one", [("elif envelope is None or (type(envelope) is int and envelope in LEGACY_ENVELOPES):", "elif True:")]), + ("envelope 3.0 is read as 3", [("if type(envelope) is int and envelope == ENVELOPE_WITH_ARTIFACTS:", "if envelope == ENVELOPE_WITH_ARTIFACTS:")]), + ("envelope true is read as 1", [("(type(envelope) is int and envelope in LEGACY_ENVELOPES)", "(envelope in LEGACY_ENVELOPES)")]), + ("the digest comparison always agrees", [("same = decode(value) == hexsha", "same = True")]), + ("a declaration that leaves a pinned file out is accepted", [("not in set(paths)]\n if missing:\n", "not in set(paths)]\n if False:\n")]), + ("no declaration counts as a declared head", [('release_artifacts): an earlier record can digest at most 6 of the 12")\n return False', 'release_artifacts): an earlier record can digest at most 6 of the 12")\n return True')]), + ("the declaration is not checked to be a list of paths", [(" if not isinstance(paths, list) or not all(isinstance(x, str) for x in paths):", " if False:")]), + ("a differently spelled path counts as the pinned one", [('f["path"] not in set(paths)', 'f["path"] not in set(p.lstrip("./") for p in paths)')]), + ("the record coverage gate becomes a pass once the head declares", [('report.add(OPEN, "record digest coverage", "the declaration only says', 'report.add(PASS, "record digest coverage", "the declaration only says')]), + ("the declaration is read at the pinned commit, not at the release head", [('blob(repo, head, "project.yaml")', 'blob(repo, pin["commit"], "project.yaml")')]), + ("the candidate ignores that the head declares", [("gates(report, args.release_date, declared=declared)", "gates(report, args.release_date, declared=False)")]), + ("the record mode ignores that the record is envelope 3", [("declared=envelope == ENVELOPE_WITH_ARTIFACTS)", "declared=False)")]), +] + + +def run_suite(tool_path): + env = {"PRECHECK_TOOL": str(tool_path), "PATH": "/usr/bin:/bin:/usr/local/bin"} + return subprocess.run([sys.executable, "-m", "pytest", str(SUITE), "-x", "-q", "-p", "no:cacheprovider"], cwd=REPO, env=env, capture_output=True, text=True, check=False) + + +def test_the_unmutated_copy_passes_the_suite(tmp_path): + copy = tmp_path / "release_candidate_precheck.py" + copy.write_text(TOOL.read_text(encoding="utf-8"), encoding="utf-8") + result = run_suite(copy) + assert result.returncode == 0, result.stdout[-3000:] + + +@pytest.mark.parametrize("name, changes", MUTANTS, ids=[m[0] for m in MUTANTS]) +def test_a_planted_defect_fails_the_suite(tmp_path, name, changes): + text = TOOL.read_text(encoding="utf-8") + for old, new in changes: + assert text.count(old) == 1, f"the text to mutate occurs {text.count(old)} times: {old[:70]!r}" + text = text.replace(old, new) + mutant = tmp_path / "release_candidate_precheck.py" + mutant.write_text(text, encoding="utf-8") + compile(text, str(mutant), "exec") # a mutant that does not compile would die for the wrong reason + result = run_suite(mutant) + assert result.returncode != 0, f"the planted defect {name!r} SURVIVED: no test noticed it" + assert "SyntaxError" not in result.stdout + result.stderr, f"{name!r} died of a syntax error" diff --git a/tools/release_candidate_precheck.py b/tools/release_candidate_precheck.py index 4428e87..0cf38e7 100644 --- a/tools/release_candidate_precheck.py +++ b/tools/release_candidate_precheck.py @@ -13,8 +13,10 @@ pin name recompute; the commit that the release will actually be cut from (--release-head: the release branch head, or the merge commit on main) holds the same twelve files; the version is a valid next increment; the files in which devel has moved on are listed. `record` after the release: the record's `provenance.source_commit` holds the twelve files byte-identical to the manifest, AND the record carries its own digest of - every one of the twelve (the owner's requirement: source_commit stays mandatory and does not stand in for the file digests). Where the record keeps them is - the record contract; the places read here are the spec sources and the two grammar files, nothing is invented. + every one of the twelve (the owner's requirement: source_commit stays mandatory and does not stand in for the file digests). Where the record keeps them + is the record contract (docs/en/RELEASE-RECORD-CONTRACT.md): an envelope 3 record keeps them in `artifacts.files` as `sha256:` of the raw blob bytes, + and only there is read; a record of an earlier envelope (1, 2) keeps at most the spec sources and the two grammar files, which are read as before. + This tool does not check the artifacts block itself (order, uniqueness, manifest_digest, signatures): that is tools/release_record_conformance.py. Exit status 1 when a hard check fails. Exit status 0 is NOT fitness for release: read `readiness` (also `--json`), which is never `ready` while an OPEN gate exists. `OPEN` lines are gates this tool does not and cannot close (a trust anchor pinned outside the record, certificate validity at the release date, the digest-encoding contract, the signing itself); they never turn into a pass. @@ -37,6 +39,10 @@ "grammar_schema_sha256": "proposals/atom-grammar/instance-grammar.schema.yaml", } COMPONENT = "primitives/" +# envelope 3 (docs/en/RELEASE-RECORD-CONTRACT.md): the record's own digest of an artifact is `sha256:` + 64 lowercase hex of the raw git blob bytes +ENVELOPE_WITH_ARTIFACTS = 3 +LEGACY_ENVELOPES = (1, 2) +ARTIFACT_DIGEST = re.compile(r"sha256:([0-9a-f]{64})") # The profile of the CIC grammar pin: EXACTLY these twelve distinct paths (cic-primitive-forms tools/grammar_identity.py GRAMMAR_FILES). It is fixed here and never read from # the manifest it judges: a manifest that dropped, added or swapped a file would otherwise define its own acceptable set. PROFILE_CIC_GRAMMAR_12 = ( @@ -208,6 +214,7 @@ def candidate(args, report): report.add(INFO, "release head differs from the candidate in", ", ".join(outside) if outside else "nothing") else: report.add(OPEN, "release head", "not given: the source_commit of the record will be the head the release runs on, and the twelve files have to be proved there too") + declared = check_declaration(report, repo, args.release_head, pin) if args.release_head else False # devel has moved on: a release cut from its head would not be the pin moved = [f["path"] for f in pin["files"] if blob(repo, args.devel, f["path"]) != blob(repo, commit, f["path"])] @@ -227,13 +234,47 @@ def candidate(args, report): report.add(INFO, "release branch", f"{COMPONENT}releases/v{args.version}") else: report.add(OPEN, "version", "not given") - gates(report, args.release_date) + gates(report, args.release_date, declared=declared) -def gates(report, release_date, record_mode=False): +def check_declaration(report, repo, head, pin): + """The release head's project.yaml declares the release artifacts (`compiler_settings.release_artifacts.paths`, the source of an envelope 3 record). -> True when it is there + and holds all the pinned paths. A declaration that leaves a pinned path out is a FAIL: the record written from that head cannot carry that file's digest. No declaration is + not a failure here (a head of an earlier release has none): the record coverage stays the OPEN gate it was. Which paths the compiler accepts is its business, not read here.""" + data = blob(repo, head, "project.yaml") + if data is None: + report.add(INFO, "release artifact declaration", "no project.yaml at the release head: an earlier record can digest at most 6 of the 12") + return False + try: + declaration = ((yaml.safe_load(data) or {}).get("compiler_settings") or {}).get("release_artifacts") + except yaml.YAMLError as e: + report.add(FAIL, "release artifact declaration", f"project.yaml at the release head does not parse: {e}") + return False + if declaration is None: + report.add(INFO, "release artifact declaration", "none at the release head (compiler_settings.release_artifacts): an earlier record can digest at most 6 of the 12") + return False + paths = declaration.get("paths") if isinstance(declaration, dict) else None + if not isinstance(paths, list) or not all(isinstance(x, str) for x in paths): + report.add(FAIL, "release artifact declaration", "compiler_settings.release_artifacts.paths at the release head is not a list of paths") + return False + missing = [f["path"] for f in pin["files"] if f["path"] not in set(paths)] + if missing: + report.add(FAIL, "release artifact declaration", f"the release head does not declare {len(missing)} of the {len(pin['files'])} pinned files as release artifacts, so its record " + "cannot carry their digests: " + "; ".join(missing)) + return False + report.add(PASS, "release artifact declaration", f"the release head declares all {len(pin['files'])} pinned files among its {len(paths)} release artifacts") + return True + + +def gates(report, release_date, record_mode=False, declared=False): + """`declared`: the release head declares all pinned files as release artifacts (an envelope 3 release). It changes the wording of two gates, never their status.""" if not record_mode: - report.add(OPEN, "record digest coverage", "the record has to carry its own digest of all 12 pinned files and the algorithm; the present compiler digests the schema sources and two " - "grammar files, so at most 6 of the 12 by construction. A record contract change, not decided or made here; until then no candidate is complete") + if declared: + report.add(OPEN, "record digest coverage", "the declaration only says what the compiler will digest: the record has to carry its own digest of all 12 pinned files, and that is " + "shown by `record` mode on the record after the release, not here") + else: + report.add(OPEN, "record digest coverage", "the record has to carry its own digest of all 12 pinned files and the algorithm; the present compiler digests the schema sources and two " + "grammar files, so at most 6 of the 12 by construction. A record contract change, not decided or made here; until then no candidate is complete") report.add(OPEN, "manifest origin", "the recomputed manifest digest shows consistency, not that the manifest comes from the entitled consumer: that is not shown by this tool") report.add(OPEN, "trust anchor", "a CIC root pinned OUTSIDE the record (verify-release --trust-root) is not supplied by this tool") report.add(OPEN, "signer authority", "the release issuer certificate is not chained to anything in the record; whether the countersignature alone authorizes a release is not decided") @@ -241,7 +282,11 @@ def gates(report, release_date, record_mode=False): report.add(OPEN, "certificate validity", f"has to be shown for {release_date} (the certificates of v0.2.0 expire 2026-12-31)") else: report.add(OPEN, "certificate validity", "no --release-date given; the certificates of v0.2.0 expire 2026-12-31") - report.add(OPEN, "digest encoding", "the base64 -> sha256:hex mapping of the record digests is not a contract yet (Forms unit with Rust/Go golden tests)") + if declared: + report.add(OPEN, "digest encoding", "the record side is a contract (envelope 3: sha256: of the raw git blob bytes, the form of the pin manifest); whether the receipt's own " + "contract takes these values as they are is not shown here (Forms unit with Rust/Go golden tests)") + else: + report.add(OPEN, "digest encoding", "the base64 -> sha256:hex mapping of the record digests is not a contract yet (Forms unit with Rust/Go golden tests)") report.add(OPEN, "signing and publishing", "HOLD by the owner until the gates above are shown") @@ -267,20 +312,26 @@ def record(args, report): # what the record digests itself, and what rests on the source commit alone by_path = {f["path"]: f["sha256"] for f in pin["files"]} - digested = {} - for spec in rec.get("specs", []): - digested[spec["source_path"]] = ("spec meta_hash", spec["meta_hash"]) - for key, path in PROVENANCE_FILES.items(): - if prov.get(key): - digested[path] = (f"provenance.{key}", prov[key]) + envelope = (rec.get("release") or {}).get("envelope") if isinstance(rec.get("release"), dict) else None + if type(envelope) is int and envelope == ENVELOPE_WITH_ARTIFACTS: + digested = artifact_digests(report, rec) + if digested is None: + gates(report, None, record_mode=True, declared=True) + return + elif envelope is None or (type(envelope) is int and envelope in LEGACY_ENVELOPES): + digested = legacy_digests(rec, prov) + else: + report.add(FAIL, "record envelope", f"release.envelope is {envelope!r}: this tool reads envelope 3 (artifacts.files) and the earlier envelopes 1 and 2 (spec sources and the two grammar files)") + gates(report, None, record_mode=True) + return covered, alone, wrong = [], [], [] for path, hexsha in by_path.items(): if path not in digested: alone.append(path) continue - where, b64 = digested[path] + where, value, decode = digested[path] try: - same = b64_to_hex(b64) == hexsha + same = decode(value) == hexsha except Exception: same = False if same: @@ -300,7 +351,45 @@ def record(args, report): report.add(FAIL, f"record digest coverage: {len(covered)} of {len(by_path)} present and equal", " | ".join(parts)) else: report.add(PASS, f"record digest coverage: all {len(by_path)} pinned files have a digest of their own in the record, and each equals the pinned file") - gates(report, None, record_mode=True) + gates(report, None, record_mode=True, declared=envelope == ENVELOPE_WITH_ARTIFACTS) + + +def legacy_digests(rec, prov): + """Envelope 1 and 2: {path: (where, base64 value, decoder)} for the spec sources and the two grammar files, the only places those records keep a digest of a file.""" + digested = {} + for spec in rec.get("specs", []): + digested[spec["source_path"]] = ("spec meta_hash", spec["meta_hash"], b64_to_hex) + for key, path in PROVENANCE_FILES.items(): + if prov.get(key): + digested[path] = (f"provenance.{key}", prov[key], b64_to_hex) + return digested + + +def artifact_digest_hex(value): + """`sha256:<64 lowercase hex>` -> the hex; anything else (uppercase, another algorithm, base64, a non-string) raises, so it is never coverage.""" + m = ARTIFACT_DIGEST.fullmatch(value) if isinstance(value, str) else None + if not m: + raise ValueError("not sha256:<64 lowercase hex>") + return m.group(1) + + +def artifact_digests(report, rec): + """Envelope 3: {path: (where, digest, decoder)} from `artifacts.files`, or None (a FAIL is reported) when the block is not there. A path listed more than once has no single + digest: it is entered with a value that never decodes, so it is reported as a wrong digest and not as coverage.""" + artifacts = rec.get("artifacts") + files = artifacts.get("files") if isinstance(artifacts, dict) else None + if not isinstance(files, list): + report.add(FAIL, "record digest coverage", "an envelope 3 record carries no artifacts.files list") + return None + seen = {} + for entry in files: + if isinstance(entry, dict) and isinstance(entry.get("path"), str): + seen.setdefault(entry["path"], []).append(entry.get("digest")) + digested = {} + for path, digests in seen.items(): + digested[path] = ("artifacts.files", digests[0] if len(digests) == 1 else None, artifact_digest_hex) + report.add(INFO, "record read", "envelope 3: artifacts.files only; the block itself (order, uniqueness, manifest_digest, signatures) is checked by tools/release_record_conformance.py, not here") + return digested def main(argv=None):