Skip to content

fix(release): assert the installers actually staged before publishing - #150

Merged
bioedca merged 1 commit into
mainfrom
fix/m9-release-stage-assert
Jul 19, 2026
Merged

fix(release): assert the installers actually staged before publishing#150
bioedca merged 1 commit into
mainfrom
fix/m9-release-stage-assert

Conversation

@bioedca

@bioedca bioedca commented Jul 19, 2026

Copy link
Copy Markdown
Owner

Summary

find ... -exec cp exits 0 when it matches nothing, even under set -euo pipefail. The two steps that would otherwise notice an empty stage — attest-build-provenance over out/*.exe|*.pkg|*.sh, and gh release create — are both gated on publish == 'true'. So a workflow_dispatch dry run stages only the two lock files, skips the gated steps, and reports green: the rehearsal that exists to build confidence before a signed release could not detect its own most important failure. This adds an unconditional completeness gate to the staging step so it fails loudly on an incomplete release — on a dry run too. Relevant now, since the pipeline has never run end-to-end and its first exercise will be a dry run before cutting v1.0.0.

Linked tracking

Type of change

  • feat — new capability
  • fix — bug fix
  • docs / chore / ci / build / refactor / test / perf
  • ! / BREAKING CHANGE: — a deliberate schema-version bump (the only sanctioned breaking change)

Self-review checklist (PRD §12.4)

  • Schema freeze respected — no .tether HDF5 change; CI-only (release.yml + a test). schema-guard green.
  • conda-lock updated if dependencies changed — no dependency change; conda-lock-verify green.
  • Tests added/updated — five contract tests in tests/test_marker_contract.py (stdlib-only, OS-agnostic, required 3-OS matrix); CodeQL clean. No GUI surface.
  • Provenance stamped — N/A: no new analysis writes to .tether; this hardens the release-artifact staging path.
  • New tunables registered in PRD §11.2 — N/A: no tunables introduced. EXPECTED_LEGS is derived from the build matrix + construct.yaml, and the contract tests bind it to both.
  • Scientific/statistical claims carry a citation; SPDX header on every new source file — no new files (both edited files already carry GPL-3.0-or-later); no scientific claims; reuse lint unaffected.
  • A resolved PRD decision that changed is reflected in the PRD / an ADR — N/A: refs existing ADR-0050; no decision changed.

What

An unconditional completeness gate in the staging step. EXPECTED_LEGS declares one <platform>:<ext> entry per build matrix leg and is the single source of truth for what a complete release contains. The gate:

  • requires a per-leg SHA256SUMS-<platform>.txt arrival receipt — the only per-platform evidence available, since installer filenames use constructor's own naming (Tether-1.0.0-MacOSX-arm64.pkg) which doesn't map back to a matrix key;
  • compares per-extension installer counts by exact equality — the two macOS legs are distinguished only by the arch constructor embeds in the .pkg name, and merge-multiple: true silently overwrites same-named files, so 2 .exe + 1 .pkg + 1 .sh still totals 4 while the Intel-Mac installer is missing;
  • rejects a zero-byte bill-of-materials, and logs the downloaded dist/ tree first (the artifact layout is the thing that silently drifts).

EXPECTED_LEGS is parsed with read -ra, not unquoted word splitting: with shopt -s nullglob enabled an unquoted leg containing a glob metacharacter is deleted rather than left literal, which would shrink the expected set and make the gate fail open. Failure terminates with a bare false, never exit (which reports "exit code 1" under the bash -el {0} login shell used elsewhere in the workflow).

Testing

  • pytest tests/test_marker_contract.py21 passed (16 existing + 5 new); ruff check + ruff format --check clean at 0.15.19; all touched YAML parses.

  • The run: script was extracted from the YAML and executed against six simulated artifact trees:

    Scenario Result
    happy path (4 installers, 4 checksums, 2 locks) exit 0 ✅
    depth-3 layout drift exit 1 ✅
    missing osx-64 leg exit 1 ✅
    2 .exe + 1 .pkg + 1 .sh (total = 4) exit 1 ✅
    zero-byte conda-lock.yml exit 1 ✅
    empty dist/ exit 1 ✅
  • Five mutation checks on the contract tests (stale EXPECTED_LEGS, if:-gated, neutered comparison, falseexit 1, unquoted loop) each turn exactly one test red.

What this does NOT cover

  • Installer content. A truncated or zero-byte .exe passes every count — and the unconditional combined-SHA256 step then re-hashes it, laundering the corruption. The staged per-platform SHA256SUMS-<platform>.txt files are never verified with sha256sum -c. Worth a follow-up.
  • The SBOM and changelog are produced by later steps and are not asserted.
  • The 8 rendered per-platform locks under dist/locks/ are downloaded and dropped by the find filter — docs/release.md promises "the frozen conda locks", which the two source locks satisfy (a documented smell, not a contradiction).
  • The -maxdepth 2 layout claim comes from upstream docs, not an observed run. The gate asserts the outcome, so it is correct at any depth; only the comment could be wrong, and the first dry run's ::group:: output confirms or corrects it for free.

`find ... -exec cp` exits 0 when it matches NOTHING, even under `set -euo
pipefail`. The two steps that would otherwise notice an empty stage —
`attest-build-provenance` over out/*.exe|*.pkg|*.sh, and `gh release create` —
are BOTH gated on `publish == 'true'`. A `workflow_dispatch` dry run therefore
staged only the two lock files, skipped the gated steps and reported GREEN: the
rehearsal that exists to build confidence before a signed release could not
detect its own most important failure.

Add an unconditional completeness gate to the staging step. `EXPECTED_LEGS`
declares one `<platform>:<ext>` entry per `build` matrix leg and is the single
source of truth for what a complete release contains. The gate requires a
per-leg `SHA256SUMS-<platform>.txt` arrival receipt, compares per-extension
installer counts by exact equality, and rejects an empty bill-of-materials.

Per-extension rather than a total of 4: the two macOS legs are distinguished
only by the arch constructor embeds in the .pkg name, and `merge-multiple: true`
silently overwrites same-named files — so 2 .exe + 1 .pkg + 1 .sh still totals 4
while the Intel-Mac installer is missing. Exact equality also catches
over-collection. The step now logs the downloaded `dist/` tree first, because
the artifact layout (dist/dist/<installer>, exactly at the `-maxdepth 2` limit
with zero headroom) is the thing that silently drifts.

`EXPECTED_LEGS` is parsed with `read -ra`, not unquoted word splitting: with
`shopt -s nullglob` enabled an unquoted leg containing a glob metacharacter is
DELETED rather than left literal, which would shrink the expected set and make
the gate fail OPEN. Failure terminates with a bare `false`, never `exit`, which
reports "exit code 1" under the `bash -el {0}` login shell used elsewhere here.

Five contract tests in tests/test_marker_contract.py keep the gate honest: they
bind `EXPECTED_LEGS`' platforms to the build matrix and its extensions to
`installer_type:` in packaging/construct.yaml, forbid `if:`/`continue-on-error`
on the gate (a dry run is precisely the run that needs it), and assert the
comparison SHAPE rather than a token that also appears in the error strings.

Verified by execution against six simulated artifact trees: happy path passes;
depth-3 layout drift, a missing osx-64 leg, a wrong 4-installer mix, a zero-byte
lock and an empty dist/ all fail loudly.

Refs: PRD §9 M9, §12.7; ADR-0050.
@coderabbitai

coderabbitai Bot commented Jul 19, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Changes

Release completeness validation

Layer / File(s) Summary
Stage release artifacts
.github/workflows/release.yml
The staging step explicitly uses Bash, enables nullglob, declares expected release legs, logs dist/, and copies installers, checksums, and frozen locks into out/.
Validate staged release contents
.github/workflows/release.yml
An unconditional gate checks checksum receipts, exact installer counts by extension, non-empty lockfiles, and fails incomplete releases with error annotations.
Enforce workflow contracts
tests/test_marker_contract.py
Contract tests validate step extraction, build-leg and constructor alignment, unconditional execution, safe parsing, per-extension counts, required receipts, and falsy failure behavior.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant ReleaseJob
  participant DistArtifacts
  participant OutputDirectory
  participant ContractTests

  ReleaseJob->>DistArtifacts: Inspect downloaded artifact tree
  ReleaseJob->>OutputDirectory: Stage installers, checksums, and frozen locks
  ReleaseJob->>OutputDirectory: Validate expected legs and artifact completeness
  OutputDirectory-->>ReleaseJob: Report missing or mismatched artifacts
  ContractTests->>ReleaseJob: Verify workflow structure and gate contracts
Loading

Possibly related PRs

Suggested labels: type:ci, area:packaging, milestone:M9, priority:P1

Poem

A rabbit checks each package bright,
With sums and locks arranged just right.
“No missing legs!” the bunny sings,
While Bash inspects the staging things.
The gate says false when flaws appear—
A tidy release hops near!

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title is concise, conventional, and accurately reflects the main change: enforcing release staging completeness before publishing.
Description check ✅ Passed The description matches the repository template and fills the required summary, tracking, type, checklist, and testing sections with relevant details.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/m9-release-stage-assert

Comment @coderabbitai help to get the list of available commands.

@bioedca

bioedca commented Jul 19, 2026

Copy link
Copy Markdown
Owner Author

@coderabbitai full review

The adaptive rate-limit window quoted earlier (47 min) has elapsed. Requesting the substantive review now — the earlier green CodeRabbit status check was the rate-limit warning, not a review.

@coderabbitai

coderabbitai Bot commented Jul 19, 2026

Copy link
Copy Markdown

You're currently rate limited under our Fair Usage Limits Policy. Your recent PR review activity is in the 95th percentile or higher among CodeRabbit users, so adaptive limits apply. Your next review will be available in 59 minutes.

@bioedca

bioedca commented Jul 19, 2026

Copy link
Copy Markdown
Owner Author

@coderabbitai full review

@coderabbitai

coderabbitai Bot commented Jul 19, 2026

Copy link
Copy Markdown
✅ Action performed

Full review finished.

@bioedca
bioedca merged commit 348e015 into main Jul 19, 2026
15 checks passed
@bioedca
bioedca deleted the fix/m9-release-stage-assert branch July 19, 2026 21:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant