release-verify
Capability: substrate:release
Harness: agnostic
Deterministic, read-only checks of a staged release candidate, one subcommand per mechanical step of the release-verify-rc skill,
plus list-keys, which lists the public keys in a KEYS file or a fetched key for release-keys-sync.
Each prints one JSON object; the skill reads it and reports.
The rules are the skill’s, implemented once in code so the model does not re-derive them from prose on every run.
Judgement stays with the reader: whether a NOTICE / LICENSE diff is material, and whether the project’s own source-tree validators passed, come back as status REVIEW.
Guarantees: nothing is written outside a throwaway GNUPGHOME; the user’s keyring is never read or changed; nothing is signed; a KEYS file containing private-key material is refused; nothing touches the network — the caller downloads the staged files and KEYS first.
Prerequisites
- Runtime: Python 3.11+ (stdlib only); run via
uv run --project tools/release-verifyor as a plainpython3 <framework>/tools/release-verify/src/release_verify/__init__.py. - CLIs:
gpg(GnuPG 2.2+) onPATHforsignatures,allandlist-keys; every other subcommand works on local files alone. Nogpg-agentis needed — verification uses public keys only. - Credentials / auth: None.
- Network: None — runs fully offline on a local copy of the staging directory, the
KEYSfile and the unpacked source artefact.
How to use
From the framework root (<framework> is .apache-magpie/ in an adopting project, . in the framework checkout).
<staging> is a local copy of the staged RC directory (e.g. svn export <staging-url> <staging>), KEYS a local copy of the project KEYS file, <tree> the unpacked source artefact.
Patterns and paths come from the adopter’s release-build.md and release-management-config.md.
RV="uv run --project <framework>/tools/release-verify release-verify"
$RV inventory --dir <staging> --expect '<pattern>'... [--expect-optional '<pattern>']... --digest sha512 [--digest sha256]
$RV signatures --dir <staging> --expect '<pattern>'... [--expect-optional '<pattern>']... --keys KEYS --keys-url <keys-url> [--extra-key <signer.pub>]
$RV checksums --dir <staging> --expect '<pattern>'... [--expect-optional '<pattern>']... --digest sha512 [--digest sha256]
$RV notice-license --tree <tree> [--previous <dir with the previous release's NOTICE and LICENSE>]
$RV binaries --tree <tree> [--prohibit '<glob>']... [--accept '<glob>']...
$RV symlinks --tree <tree> [--validator '<command>']...
$RV version --tree <tree> --rc-tag <version>-rcN --manifest <path>[=<regex>]...
$RV verdict <step.json>... [--status <step>=PASS|WARN|FAIL|SKIP]...
$RV list-keys --file <KEYS or fetched-key.asc>
$RV all --dir <staging> --tree <tree> <the options above> [--status <step>=<status>]...
--expect takes each artefact release-build.md § Expected artefact list marks required (the default), --expect-optional each one it marks optional; signatures and checksums check every staged artefact of either kind.
inventory also takes --listing (a file of names, e.g. svn ls <staging-url> output) in place of --dir. --keys-url is only printed in the voter’s paste_recipe.
Exit status is 0 whenever a JSON result is printed, whatever the verdict; 2 with {"error": …} for unusable input.
Output
Every step object carries step, status and the step’s fields as the skill’s hand-back schema names them, plus a few extra diagnostic fields (marked † below).
| Subcommand | step |
status |
Fields |
|---|---|---|---|
inventory |
inventory |
FAIL a required pattern unmatched · WARN an optional pattern unmatched, or an unexpected entry · else PASS |
found, missing (required patterns), missing_optional†, unexpected, source† |
signatures |
signatures |
PASS only when every artefact is PASS, else FAIL |
results[] (file, sig_file, classification PASS / KEY-NOT-IN-KEYS / FAIL, fingerprint, key_in_keys, detail†), keys_fingerprints†, paste_recipe |
checksums |
checksums |
FAIL a sha512 MISSING-DIGEST, or a MISMATCH on any digest but md5 · WARN a .md5 file is present (matching or not) · else PASS |
results[].digests[] (type, classification, detail†), deprecated_md5_present, paste_recipe |
notice-license |
notice-license |
FAIL either file absent from the RC artefact · PASS no previous release, or both diffs empty · else REVIEW |
notice_present, license_present, notice_diff_lines, license_diff_lines, notice_diff†, license_diff†, previous†, detail† (on FAIL: which file the current RC artefact lacks) |
binaries |
binary-exclusion |
FAIL when prohibited_found is non-empty |
prohibited_found, expected_binaries, paste_recipe |
symlinks |
source-tree-integrity |
FAIL a symlink that dangles or resolves outside the archive · REVIEW validators declared (run them) · PASS symlinks, all resolve inside · SKIP no symlinks, no validators |
dangling_symlinks, outside_symlinks†, symlinks_present†, validator_failures (always []), validators_to_run†, paste_recipe |
version |
version-consistency |
FAIL any extracted is null or differs from the expected version |
expected_version, results[] (file, extracted, match, detail†) |
list-keys |
list-keys |
— | file (base name only), keys[] (fingerprint, validity, algo_id, algorithm, bits, curve, created, expiry, caps, uids, subkey_fingerprints, subkeys[] with each subkey’s own fingerprint, validity, algo_id, algorithm, bits, curve, created, expiry, caps), detail† (why no key was listed). A file that is not a regular file, a failed gpg run and a public-key block that does not import are an error; only an import that found no key data is an empty keys |
verdict |
verdict |
— | overall (FAIL > PASS-WITH-WARNINGS > PASS; null while a step is unresolved), step_summary, fail_steps, warn_steps, skip_steps, unresolved, ignored_overrides† |
Rules the subcommands apply:
- Signatures. The
KEYSfile is imported into a temporaryGNUPGHOMEwith no agent, keyboxd or key retrieval.PASSwhengpg --verifyexits 0 and the signing key (primary or subkey fingerprint) is inKEYS.KEY-NOT-IN-KEYSwhen the signature is good but the key is not inKEYS; telling that apart needs the signer’s public key, passed with--extra-key(never a trust anchor). Without it such a signature cannot be checked and isFAILwithdetailnaming the missing key. A missing.ascisFAIL, and so is a signature by a revoked or expired key or an expired signature (gpg still exits 0 for those, so the tool readsREVKEYSIG/EXPKEYSIG/EXPSIGfrom its status output). Every value in apaste_recipeis shell-quoted, because artefact names come from the staging area; a local--keyspath appears only by its file name, and gpg messages never carry the temporary home path. - Inventory. A missing required artefact is
FAIL; a missing optional one isWARN(listed inmissing_optional), neverFAIL. - Checksums. Only
sha512is required: a missing.sha512isMISSING-DIGEST, aFAIL. Every other digest (sha256, whether or not passed as--digest) is optional — checked when its file is staged, absent otherwise without effect — and aMISMATCHon one stillFAILs. md5 never fails alone: a.md5file, matching or mismatched, is listed and makes the stepWARN. Digest files insha512sum, BSD-tag (SHA512 (f) = …) andgpg --print-mdlayouts are read; an unparsable one is aMISMATCHwithdetail. - Binaries. Fixed baseline
*.class,*.jar,*.so,*.dylib,*.dll,*.exe,*.pyc(files) and__pycache__(directories), plus every--prohibitglob. A hit matching an--acceptglob isexpected_binaries, any other isprohibited_found;.pycand__pycache__are always prohibited. A glob without/matches the file name (find -name), one with/the path inside the tree (find -path,**=*). - Notice / licence. On
FAIL,detailnames the file the current RC artefact lacks; the diff counts arenullbecause there is nothing to diff, not because a previous release is missing. - Symlinks. Every symlink must resolve to an existing path inside the unpacked archive. One whose target does not exist is dangling; one that resolves outside the archive (
..past the root, an absolute path, or a chain through either) is listed inoutside_symlinksand alsoFAILs, even when the target exists.paste_reciperepeats both checks withrealpath. Validators are printed inpaste_recipe, never run. - Version.
--rc-tagdrops the-rcNsuffix. Built-in extraction forsetup.cfg([metadata] version),pyproject.toml([project]or[tool.poetry]),*.py(__version__ = "…";version="…"insetup.py),pom.xml(<project><version>),package.json,Cargo.toml,*.properties,build.gradle(.kts),Chart.yaml,VERSION/version.txt. Anything else needsPATH=REGEX(first group). Dynamic versions (attr:,dynamic = ["version"]) arenull. - List keys. The file is refused when it holds private-key material, and otherwise imported into a fresh throwaway
GNUPGHOME(no agent, keyboxd or key retrieval) and listed with--with-colons --fixed-list-mode. Each primary key comes with its fingerprint, gpg’s validity field, algorithm, size, curve, creation and expiry dates (YYYY-MM-DD), its user IDs (revoked and expired ones left out, primary first) and its subkey fingerprints. An empty file, or one gpg finds no key in, listskeys: []withdetail— not an error — so a keyserver 404 saved as an empty file reads as “no key”. It judges nothing:release-management keys-syncapplies the strength floor and the KEYS lookup. - Verdict. A status the tool computed is final;
--statusonly fills a step it did not run (rat-license-headers,reproducibility) or resolves aREVIEW.SKIPneither passes nor warns, andskip_stepslists every skipped step so the report names it.
Wiring
release-verify-rcSteps 1–3 and 5–8 run the matching subcommand, and Step 10 runsverdict(or the skill runsallonce). Step 4 (Apache RAT) is build-system specific and Step 9 usesreproducible-archive; both report their status intoverdictwith--status.release-management verify-rc argswrites every invocation fromrelease-build.md, andverify-rc source-treeresolves a Step 7REVIEW.release-keys-syncSteps 0 and 1 runlist-keyson theKEYSfile and on the key fetched from the keyserver, and hand the JSON torelease-management keys-sync verdict/check.- The workspace pre-commit hooks (
ruff,mypy,pytest) pick the project up from the rootpyproject.toml[tool.uv.workspace] memberslist.
Tests
uv run --project tools/release-verify python -m pytest
Every subcommand is exercised on its pass case and on each failure class the skill names.
The signature tests use static fixtures in tests/fixtures/gpg/ — public keys and detached signatures only, written by generate.py without gpg-agent — so they need gpg but run in a sandbox; they skip cleanly when gpg is absent.
One further test generates a throwaway key in a temporary GNUPGHOME and skips where no agent can start.