From f3125aa19fae241255137261f83680d6ae1ffd61 Mon Sep 17 00:00:00 2001 From: Catalyst Runner Date: Mon, 7 Sep 2026 05:44:33 +0000 Subject: [PATCH] MAN-84: squashed replay of 3 commit(s) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit edd86eb feat: MAN-84 — Operators should be able to download a v0.1.0 release instead of building manta from source 6618f2a feat: MAN-84 — Operators should be able to download a v0.1.0 release instead of building manta from source d292166 feat: MAN-84 — Operators should be able to download a v0.1.0 release instead of building manta from source Catalyst-Replay-Squash: e398d46470fba19a74b645cf203b2e3f9ff8dbdd --- .github/workflows/release-publish.yml | 56 +++++++- README.md | 5 +- crates/manta-cli/tests/release_copy.rs | 128 ++++++++++++++++++ docs/RUNBOOKS/release.md | 174 +++++++++++++++++++++++++ 4 files changed, 359 insertions(+), 4 deletions(-) create mode 100644 crates/manta-cli/tests/release_copy.rs create mode 100644 docs/RUNBOOKS/release.md diff --git a/.github/workflows/release-publish.yml b/.github/workflows/release-publish.yml index 82938404..847fd564 100644 --- a/.github/workflows/release-publish.yml +++ b/.github/workflows/release-publish.yml @@ -64,6 +64,35 @@ permissions: contents: read jobs: + # Fails fast, in parallel with the build matrix, if the pushed tag and + # the workspace version disagree. Without this, tagging v0.2.0 without + # bumping Cargo.toml publishes a "v0.2.0" Release whose binaries answer + # `manta 0.1.0` to --version -- exactly the kind of dishonesty MAN-84 + # exists to remove. Deliberately NOT a `needs:` of `build` (a skipped + # job makes its dependents skip too, and `build` must still run on + # workflow_dispatch, where there is no tag to check). + verify-version: + if: github.event_name == 'push' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Tag must match [workspace.package] version + shell: bash + run: | + set -euo pipefail + TAG_VERSION="${GITHUB_REF_NAME#v}" + CARGO_VERSION="$(sed -n '/^\[workspace.package\]/,/^\[/p' Cargo.toml \ + | sed -n 's/^version = "\(.*\)"$/\1/p' | head -1)" + echo "tag=$GITHUB_REF_NAME -> $TAG_VERSION ; Cargo.toml -> $CARGO_VERSION" + if [[ -z "$CARGO_VERSION" ]]; then + echo "::error::Could not read [workspace.package] version from Cargo.toml" + exit 1 + fi + if [[ "$TAG_VERSION" != "$CARGO_VERSION" ]]; then + echo "::error::Tag $GITHUB_REF_NAME implies version $TAG_VERSION but [workspace.package] version is $CARGO_VERSION. Bump Cargo.toml (and Cargo.lock) on main first, then retag." + exit 1 + fi + build: strategy: fail-fast: false @@ -147,7 +176,12 @@ jobs: retention-days: 14 docker-publish: - needs: build + needs: [verify-version, build] + # verify-version only runs `if: github.event_name == 'push'`, so it is + # SKIPPED (not failed) on workflow_dispatch -- this must accept skipped + # alongside success, or a manual dispatch publish would wrongly skip + # too. `!cancelled()` mirrors the `release` job's own guard below. + if: ${{ !cancelled() && needs.build.result == 'success' && (needs.verify-version.result == 'success' || needs.verify-version.result == 'skipped') }} runs-on: ubuntu-latest permissions: contents: read @@ -214,8 +248,15 @@ jobs: tags: ${{ steps.version.outputs.tags }} release: - needs: [build, docker-publish] - if: github.event_name == 'push' + needs: [verify-version, build, docker-publish] + # NOT a plain `needs:` gate on docker-publish. MAN-84 makes the + # GitHub Release binaries the priority deliverable and explicitly + # does not block on GHCR (MAN-65 finding 4 / MAN-66); a first-ever + # GHCR package push is the leg most likely to fail, and a plain + # `needs:` would silently take the whole Release down with it. + # `!cancelled()` rather than `always()` so a cancelled run still + # cancels this job. + if: ${{ !cancelled() && github.event_name == 'push' && needs.build.result == 'success' && needs.verify-version.result == 'success' }} runs-on: ubuntu-latest permissions: contents: write @@ -231,4 +272,13 @@ jobs: - uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2.6.2 with: files: dist/* + # GitHub's create-release API PRE-PENDS `body` to the notes it + # generates when generate_release_notes is also true, so this + # banner lands above the auto-generated changelog rather than + # replacing it (MAN-84 / decision D7, 2026-09-06 broad review). generate_release_notes: true + body: | + > **Pre-stability alpha, expect breakage.** manta has not yet + > cleared its own M2/M3 acceptance criteria (see ROADMAP.md). + > CLI flags, config keys, and the JSON spot schema can change + > without a deprecation path before 1.0. diff --git a/README.md b/README.md index 286978ab..a32d9b04 100644 --- a/README.md +++ b/README.md @@ -133,7 +133,10 @@ test vectors. ## Status -Pre-1.0. What exists and what does not: +**Pre-stability alpha, expect breakage.** `v0.1.0` is manta's current +version; it has not cleared its own M2/M3 acceptance gates (below). CLI +flags, config keys, and the JSON spot schema can still change without a +deprecation path before 1.0. What exists and what does not: - **Done:** single-signal decode from files and live audio (M1); the full wideband pipeline of polyphase channelizer, detector, track manager, and diff --git a/crates/manta-cli/tests/release_copy.rs b/crates/manta-cli/tests/release_copy.rs new file mode 100644 index 00000000..7bef3689 --- /dev/null +++ b/crates/manta-cli/tests/release_copy.rs @@ -0,0 +1,128 @@ +//! MAN-84 / decision D7 (2026-09-06 broad review): the properties that make +//! a published manta release honest, asserted in CI rather than trusted to a +//! reviewer's memory. +//! +//! These are text assertions over repo files, not over manta's behaviour, so +//! they read oddly next to the golden-vector tests around them. They live +//! here — in `manta-cli`, the crate whose binary is what a release actually +//! ships — because `cargo test --workspace` is what this repo's REQUIRED CI +//! checks run (`test (ubuntu-latest)` / `test (macos-latest)`, ci.yml). A new +//! standalone job would not be required and so would not gate a merge, and a +//! shell script in ci.yml's `test` job would have to stay bash-3.2-clean for +//! the macOS leg (the constraint MAN-65's own plan works under). A Rust test +//! sidesteps both. + +use std::path::{Path, PathBuf}; + +/// Walk up from this crate to the workspace root. Anchored on two files that +/// must both exist there, so a stray `README.md` in an intermediate directory +/// can't produce a false root. +fn repo_root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .ancestors() + .find(|p| p.join("README.md").is_file() && p.join(".github/workflows").is_dir()) + .expect("repo root not found from CARGO_MANIFEST_DIR") + .to_path_buf() +} + +fn read_lowercased(rel: &str) -> String { + let path = repo_root().join(rel); + std::fs::read_to_string(&path) + .unwrap_or_else(|e| panic!("cannot read {}: {e}", path.display())) + .to_lowercase() +} + +/// D7 pins this exact phrase. Compared case-insensitively: prose capitalises +/// it at the start of a sentence ("**Pre-stability alpha, expect breakage.**") +/// and a case-sensitive check false-fails on correct copy. +const D7_PHRASE: &str = "pre-stability alpha, expect breakage"; + +#[test] +fn readme_status_declares_pre_stability_alpha() { + assert!( + read_lowercased("README.md").contains(D7_PHRASE), + "README.md must say {D7_PHRASE:?} (MAN-84 scenario 2, decision D7). \ + The Installation section promises downloadable binaries; the Status \ + section is where a reader learns how much to trust them." + ); +} + +#[test] +fn release_notes_declare_pre_stability_alpha() { + assert!( + read_lowercased(".github/workflows/release-publish.yml").contains(D7_PHRASE), + "the `release` job's action-gh-release `body:` must say {D7_PHRASE:?} \ + (MAN-84 scenario 2). `generate_release_notes: true` alone yields only \ + an auto-generated PR list, with no statement of stability at all." + ); +} + +/// Extracts the top-level job block named `name` from a GitHub Actions +/// workflow file: the `name:` header line under `jobs:` (2-space indent) +/// plus every following line, up to but not including the next 2-space- +/// indented, non-comment line (the next job). Scoping assertions to a named +/// job's own block, rather than grepping the whole file, means a string +/// that happens to occur in a NEIGHBOURING job can't satisfy a guard meant +/// for this one. +fn job_block(wf: &str, name: &str) -> String { + let header = format!(" {name}:"); + let start = wf + .lines() + .position(|l| l == header) + .unwrap_or_else(|| panic!("job `{name}` not found in workflow (looked for {header:?})")); + let mut block = String::new(); + for line in wf.lines().skip(start) { + if !block.is_empty() + && line.starts_with(" ") + && !line.starts_with(" ") + && !line.trim_start().starts_with('#') + { + break; + } + block.push_str(line); + block.push('\n'); + } + block +} + +/// The (single-line) `if:` condition within a job block, as extracted by +/// `job_block`. +fn if_condition(block: &str) -> &str { + block + .lines() + .find(|l| l.trim_start().starts_with("if:")) + .unwrap_or_else(|| panic!("no `if:` line found in job block:\n{block}")) +} + +#[test] +fn github_release_is_not_gated_on_the_ghcr_push() { + let wf = std::fs::read_to_string(repo_root().join(".github/workflows/release-publish.yml")) + .expect("cannot read release-publish.yml"); + + let release_block = job_block(&wf, "release"); + let release_if = if_condition(&release_block); + assert!( + release_if.contains("needs.build.result == 'success'"), + "the `release` job's own `if:` line must gate on the build matrix \ + succeeding (MAN-84: the GitHub Release binaries are the \ + deliverable). Line was: {release_if:?}" + ); + assert!( + !release_if.contains("docker-publish"), + "the `release` job's own `if:` line must not reference \ + `docker-publish`'s result -- GHCR is MAN-65 finding 4 / MAN-66 and \ + explicitly not a blocker. A gate on docker-publish's result would \ + silently take the whole GitHub Release down with a failed GHCR \ + push. Line was: {release_if:?}" + ); + + let verify_version_block = job_block(&wf, "verify-version"); + assert!( + verify_version_block.contains(r#""$TAG_VERSION" != "$CARGO_VERSION""#), + "release-publish.yml must keep the `verify-version` job's actual \ + tag-vs-[workspace.package] comparison, not just the job's name or \ + an emptied-out script: a tag whose version disagrees with the \ + workspace would otherwise publish a Release whose binaries answer \ + a different version to --version. Job block was:\n{verify_version_block}" + ); +} diff --git a/docs/RUNBOOKS/release.md b/docs/RUNBOOKS/release.md new file mode 100644 index 00000000..745934f9 --- /dev/null +++ b/docs/RUNBOOKS/release.md @@ -0,0 +1,174 @@ +# Cutting a manta release + +The release *pipeline* (`.github/workflows/release.yml` + +`.github/workflows/release-publish.yml`, MAN-21) is fully automated. The one +step that isn't, and can't safely be, is pushing the tag: that's a +deliberate human act — the moment someone decides "this commit is what we're +calling v0.1.0" — not an automation gap. `release-publish.yml`'s header +comment explains why the publish path has no `pull_request` trigger at all +(credential isolation); the same reasoning is why nothing in CI pushes tags +on your behalf. + +Every release **must** carry the pre-stability-alpha statement (decision +[D7](../DECISIONS/2026-09-06-broad-review-decisions.md)) until manta clears +its M2/M3 acceptance gates (see `README.md` § Status and `ROADMAP.md`). CI +enforces this for the README and the release-notes template via +`crates/manta-cli/tests/release_copy.rs` (part of `cargo test --workspace`, +this repo's required check) — but the tag's *own* annotation message is not +checked by anything, so write it by hand as shown below. + +## Before you push the tag + +1. **`origin/main` is clean and up to date:** + + ```sh + git fetch origin + git switch main + git reset --hard origin/main + git status --porcelain # must be empty + ``` + +2. **The tag version matches `[workspace.package]` in `Cargo.toml`.** The + release pipeline's own `verify-version` job re-checks this on the tag + push and fails the release if it disagrees, but catching it here saves a + failed run: + + ```sh + sed -n '/^\[workspace.package\]/,/^\[/p' Cargo.toml | grep '^version =' + ``` + + If the workspace version needs to change, do that as a normal PR to + `main` first, merge it, and only then tag the new merge commit — never + bump the version in the same breath as tagging an old commit. + +3. **CI is green on the commit you're about to tag.** Check the `test + (ubuntu-latest)` / `test (macos-latest)` required checks on that commit + in the GitHub UI or via `gh`. + +4. **No tag or release already exists for this version:** + + ```sh + git tag -l 'v*' + git ls-remote --tags origin 'v*' + ``` + + Both should be empty (or, for a later release, should not already + contain the version you're about to cut). + +## Push the tag + +Use an **annotated** tag, not a lightweight one — the annotation message is +what `git show ` and several GitHub UI surfaces display, and it's the +one place in this whole procedure the pre-stability wording is written by a +human rather than templated: + +```sh +git tag -a v0.1.0 -m "manta v0.1.0 — pre-stability alpha, expect breakage" +git push origin v0.1.0 +``` + +Pushing the tag triggers both workflow files: `release.yml` (build-and- +validate, `pull_request`-safe, redundant with the publish build but +harmless) and `release-publish.yml` (the one that matters — `verify-version`, +the five-target `build` matrix, `docker-publish`, and `release`). + +## Watch the run + +```sh +gh run list --workflow release-publish.yml --limit 1 +gh run watch "$(gh run list --workflow release-publish.yml --limit 1 --json databaseId --jq '.[0].databaseId')" +``` + +Expected shape and rough durations: + +- `verify-version` — under a minute. If this fails, the tag and + `Cargo.toml` disagree; see "If the release is wrong" below. +- `build` (5 legs: macOS x86_64, macOS arm64, Windows MSVC, Linux x86_64, + Linux arm64) — 10–25 minutes. The two Linux legs are the long pole; they + build via `cross`, which installs itself (`cargo install cross`) before + it can build anything. +- `docker-publish` — 10–20 minutes; builds `linux/amd64` and `linux/arm64` + under QEMU emulation for the non-native arch, which is slow by nature. +- `release` — seconds, once `build` and `verify-version` have both + succeeded. Per this repo's decision to prioritize the GitHub Release + binaries over the GHCR image (MAN-84, MAN-65 finding 4 / MAN-66), `release` + does **not** wait on `docker-publish` to succeed — only to finish. A + failed `docker-publish` does not block the Release. + +## Verify the release is real + +Don't trust the green checkmarks alone — check the artifact as an operator +would, from a directory that is **not** a clone of this repo: + +```sh +gh release view v0.1.0 --json assets --jq '.assets[].name' +``` + +Expect exactly five archives: `manta-macos-x86_64.tar.gz`, +`manta-macos-arm64.tar.gz`, `manta-linux-x86_64.tar.gz`, +`manta-linux-arm64.tar.gz`, `manta-windows-x86_64.zip`. + +```sh +gh release view v0.1.0 --json body --jq .body | head -5 +``` + +Expect the pre-stability-alpha banner **above** the auto-generated +changelog (GitHub's `create-release` API prepends `body:` to +`generate_release_notes` output — not independently re-verified against a +real GitHub API response as of this writing; if the banner is missing or +the changelog replaced it instead of following it, that assumption was +wrong — edit the published notes by hand to unblock the release, then fix +`release-publish.yml`'s `release` job as a follow-up, e.g. by moving the +banner into the tag annotation or composing the body without +`generate_release_notes`). + +Then, from a scratch directory with no manta checkout and no Rust +toolchain assumed: + +```sh +mkdir -p /tmp/manta-release-check && cd /tmp/manta-release-check +gh release download v0.1.0 --pattern 'manta-linux-x86_64.tar.gz' +tar xzf manta-linux-x86_64.tar.gz +cd manta-linux-x86_64 +./manta --version # expect: manta 0.1.0 +./manta gen v1 --out ./v1 && ./manta decode ./v1/v1.wav # expect W1AW text, spots: 1 +``` + +Repeat the download-unpack-run check for at least one non-Linux archive on +a machine of that platform — the macOS, Windows, and `arm64` legs are built +in CI and were never run outside it before this release. + +Finally, confirm `README.md`'s release badge +(`img.shields.io/github/v/release/HagaleTechnologies/manta`) now renders +`v0.1.0` instead of "no releases found". + +## After + +- **Make the GHCR package public** (one-time, per package, not per + release): GitHub package settings → `manta` → Package settings → Change + visibility → Public. This is tracked as MAN-66 and is explicitly **not a + release blocker** — close out the release even if this step hasn't + happened yet, and leave MAN-66 open until it has. +- If `docker-publish` failed while `release` still published (the behavior + this pipeline is deliberately configured to allow), re-run **only** that + job (`gh run rerun --job `) rather than + re-tagging. Do not delete and re-push the tag for a GHCR-only failure — + the GitHub Release binaries are the deliverable that matters. + +## If the release is wrong + +If the tag was pushed against the wrong commit, or `verify-version` should +have failed but didn't, or the release needs to be pulled entirely: + +```sh +gh release delete v0.1.0 --yes # removes the GitHub Release + its assets +git push origin :refs/tags/v0.1.0 # removes the tag from origin +git tag -d v0.1.0 # removes the local tag +``` + +**None of these remove the Docker image tag already pushed to GHCR** — a +`docker-publish` run that succeeded before you noticed the problem leaves +`ghcr.io/hagaletechnologies/manta:0.1.0` (and, if this was the first tag, +`:latest`) in place. Delete those manually from the package's GitHub UI +(Package settings → Manage versions) if they need to go too, and re-tag +once the underlying problem is fixed.