Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 53 additions & 3 deletions .github/workflows/release-publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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.
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
128 changes: 128 additions & 0 deletions crates/manta-cli/tests/release_copy.rs
Original file line number Diff line number Diff line change
@@ -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}"
);
}
174 changes: 174 additions & 0 deletions docs/RUNBOOKS/release.md
Original file line number Diff line number Diff line change
@@ -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 <tag>` 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 <run-id> --job <docker-publish-job-id>`) 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.
Loading