name: Release
on:
push:
tags: ["v*"]
workflow_dispatch:
inputs:
tag:
description: "Tag to build (dry run — no release is published)"
required: false
permissions:
contents: write
defaults:
run:
shell: bash
jobs:
guard:
name: the tag says what the manifest says
runs-on: ubuntu-latest
outputs:
version: ${{ steps.v.outputs.version }}
dry_run: ${{ steps.v.outputs.dry_run }}
steps:
- uses: actions/checkout@v4
- id: v
run: |
set -euo pipefail
manifest=$(grep -m1 '^version = ' Cargo.toml | cut -d'"' -f2)
if [ "${{ github.ref_type }}" = "tag" ]; then
tag="${GITHUB_REF_NAME#v}"
dry=false
else
tag="${{ inputs.tag }}"
tag="${tag#v}"
[ -n "$tag" ] || tag="$manifest"
dry=true
fi
echo "manifest=$manifest tag=$tag dry_run=$dry"
if [ "$manifest" != "$tag" ]; then
echo "::error::tag v$tag does not match the manifest version $manifest."
echo "::error::Commit the version bump FIRST, confirm HEAD moved, then tag."
exit 1
fi
# The release notes lead with CHANGELOG.md's section for this
# version, so a missing section would publish a release that opens
# with nothing. Refuse now, while writing it costs one commit
# instead of a re-release.
if ! awk -v ver="v$manifest" '$1 == "##" && $2 == ver { found = 1 } END { exit !found }' CHANGELOG.md; then
echo "::error::CHANGELOG.md has no '## v$manifest' section."
echo "::error::Write what the upgrader gets, then tag."
exit 1
fi
echo "version=$manifest" >> "$GITHUB_OUTPUT"
echo "dry_run=$dry" >> "$GITHUB_OUTPUT"
build:
name: ${{ matrix.target }}
needs: guard
runs-on: ${{ matrix.os }}
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
include:
- target: x86_64-unknown-linux-gnu
os: ubuntu-latest
- target: x86_64-unknown-linux-musl
os: ubuntu-latest
packages: musl-tools
- target: aarch64-unknown-linux-gnu
os: ubuntu-latest
packages: gcc-aarch64-linux-gnu
linker: aarch64-linux-gnu-gcc
- target: aarch64-apple-darwin
os: macos-latest
- target: x86_64-apple-darwin
os: macos-latest
- target: x86_64-pc-windows-msvc
os: windows-latest
steps:
- uses: actions/checkout@v4
- name: Install toolchain for ${{ matrix.target }}
run: rustup target add ${{ matrix.target }}
- name: Install cross-compilation packages
if: matrix.packages != ''
timeout-minutes: 5
run: |
# The runner routes apt through `mirror+file:/etc/apt/apt-mirrors.txt`,
# so rewriting the sources files alone still let every fetch start at
# the Azure mirror. Rewrite the mirror LIST, which is the knob the
# image actually reads.
if [ -f /etc/apt/apt-mirrors.txt ]; then
printf 'http://archive.ubuntu.com/ubuntu\tpriority:1\n' | sudo tee /etc/apt/apt-mirrors.txt
fi
sudo sed -i 's|azure.archive.ubuntu.com|archive.ubuntu.com|g' \
/etc/apt/sources.list /etc/apt/sources.list.d/*.sources 2>/dev/null || true
sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=15 \
-o Acquire::https::Timeout=15 update
sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=15 \
-o Acquire::https::Timeout=15 install -y ${{ matrix.packages }}
- name: Point cargo at the cross linker
if: matrix.linker != ''
run: |
target_upper=$(echo "${{ matrix.target }}" | tr 'a-z-' 'A-Z_')
echo "CARGO_TARGET_${target_upper}_LINKER=${{ matrix.linker }}" >> "$GITHUB_ENV"
- run: cargo build --release --locked --target ${{ matrix.target }}
- name: Archive
id: archive
run: |
set -euo pipefail
version="${{ needs.guard.outputs.version }}"
name="amont-agent-${version}-${{ matrix.target }}"
staging="dist/$name"
mkdir -p "$staging"
cp README.md LICENSE "$staging/"
if [ "${{ runner.os }}" = "Windows" ]; then
cp "target/${{ matrix.target }}/release/amont-agent.exe" "$staging/"
(cd dist && 7z a "$name.zip" "$name" > /dev/null)
echo "asset=dist/$name.zip" >> "$GITHUB_OUTPUT"
else
cp "target/${{ matrix.target }}/release/amont-agent" "$staging/"
(cd dist && tar czf "$name.tar.gz" "$name")
echo "asset=dist/$name.tar.gz" >> "$GITHUB_OUTPUT"
fi
- name: The packaged binary runs, and still says no
if: matrix.target == 'x86_64-unknown-linux-gnu' || matrix.target == 'aarch64-apple-darwin' || matrix.target == 'x86_64-pc-windows-msvc'
run: |
set -euo pipefail
version="${{ needs.guard.outputs.version }}"
bin="dist/amont-agent-${version}-${{ matrix.target }}/amont-agent"
# An `if`, not `[ … ] && …`: under `set -e` a false test as the last
# command of a line is a FAILING step.
if [ "${{ runner.os }}" = "Windows" ]; then bin="$bin.exe"; fi
# CAPTURE FIRST, then grep the variable — never `"$bin" | grep -q`.
#
# This step runs under `bash -e -o pipefail`. `grep -q` exits at its
# first match and closes the pipe, and the binary — correctly, since
# v2.0.0 — then dies of SIGPIPE with status 141 like any Unix
# filter. Under pipefail that is a FAILED step, so the more
# correctly the binary behaves the harder this breaks.
#
# Both spellings were wrong here in turn, which is why this comment
# is long: before the SIGPIPE fix the same two lines failed with 101
# (a Rust panic writing to the closed pipe) on this same target and
# no other. A command substitution drains the output completely, so
# there is no early reader and no signal either way.
help=$("$bin" --help)
printf '%s' "$help" | grep -q 'backtest'
rules=$("$bin" rules)
printf '%s' "$rules" | grep -q 'pipe-to-tail'
# The real thing: one command through the real rule engine.
out=$("$bin" check 'git push origin main 2>&1 | tail -5')
printf '%s\n' "$out"
printf '%s' "$out" | grep -q 'pipe-to-tail' || {
echo "::error::the packaged binary did not refuse a piped push"; exit 1; }
echo "the packaged binary runs and still decides"
- uses: actions/upload-artifact@v4
with:
name: ${{ matrix.target }}
path: ${{ steps.archive.outputs.asset }}
if-no-files-found: error
audit:
name: no known vulnerabilities ship
needs: guard
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v4
- name: Install cargo-audit
run: cargo install --locked cargo-audit
- name: Audit
run: |
set -euo pipefail
if ! cargo audit --json > audit.json; then
# Distinguish "found something" from "could not ask". Only the
# latter is fatal here regardless of content.
if [ ! -s audit.json ]; then
echo "::error::cargo audit produced no output — the tree is UNVERIFIED, and an unverified tree does not ship."
exit 1
fi
fi
python3 - <<'PY'
import json, pathlib, sys
raw = pathlib.Path("audit.json").read_text()
if not raw.strip():
print("::error::empty audit report — nothing was verified")
sys.exit(1)
d = json.loads(raw)
vulns = d.get("vulnerabilities", {}).get("list", [])
for v in vulns:
a = v.get("advisory", {})
print(f"::error::{a.get('id')} {a.get('package')}: {a.get('title')}")
for kind, items in d.get("warnings", {}).items():
for w in items:
a = (w.get("advisory") or {})
print(f"::warning::{kind}: {a.get('package') or w.get('package')}")
if vulns:
print(f"::error::{len(vulns)} vulnerability advisories — not shipping")
sys.exit(1)
print("no vulnerability advisories")
PY
publish:
name: publish the release
needs: [guard, build, audit]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v4
with:
path: dist
merge-multiple: true
- name: Checksums
run: |
set -euo pipefail
cd dist
ls -la
sha256sum * > SHA256SUMS
cat SHA256SUMS
- name: Publish
if: needs.guard.outputs.dry_run == 'false'
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
# Idempotent, because re-running a release is a normal thing to want:
# the reason to re-run is usually that a LATER step failed.
if gh release view "${GITHUB_REF_NAME}" > /dev/null 2>&1; then
echo "release ${GITHUB_REF_NAME} already exists — refreshing its assets"
gh release upload "${GITHUB_REF_NAME}" dist/* --clobber
else
# Notes an upgrader can read, then the generated PR list. Matching
# on $2 rather than a prefix so extracting v2.0.2 can never grab
# v2.0.21.
notes=$(mktemp)
awk -v ver="${GITHUB_REF_NAME}" '
$1 == "##" && $2 == ver { grab = 1; next }
$1 == "##" && grab { exit }
grab { print }
' CHANGELOG.md > "$notes"
printf '\n---\n\n' >> "$notes"
gh api "repos/${GITHUB_REPOSITORY}/releases/generate-notes" \
-f tag_name="${GITHUB_REF_NAME}" --jq .body >> "$notes"
gh release create "${GITHUB_REF_NAME}" \
--title "${GITHUB_REF_NAME}" \
--notes-file "$notes" \
dist/*
fi
- name: Dry run summary
if: needs.guard.outputs.dry_run == 'true'
run: |
echo "### Dry run — nothing published" >> "$GITHUB_STEP_SUMMARY"
echo '```' >> "$GITHUB_STEP_SUMMARY"
cat dist/SHA256SUMS >> "$GITHUB_STEP_SUMMARY"
echo '```' >> "$GITHUB_STEP_SUMMARY"
publish-crates:
name: publish to crates.io
needs: [guard, build, publish]
if: github.ref_type == 'tag'
runs-on: ubuntu-latest
timeout-minutes: 20
permissions:
contents: read
env:
VERSION: ${{ needs.guard.outputs.version }}
CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
steps:
- uses: actions/checkout@v4
- name: Publish
run: |
set -euo pipefail
# No poll after the upload. cargo ALREADY waits for it to become
# available — the run log reads "waiting for X to be available at
# registry" then "Published X" — and the crates.io API rate-limits
# datacenter IPs, so a poll on top saw non-200 for a crate that had
# just published fine and failed the job ten minutes later.
#
# Idempotency comes from cargo's own answer instead: re-running a
# release must not fail on a crate that is already up, and "already
# uploaded" is exactly that answer — anything else is a real failure.
echo "→ publishing amont-agent $VERSION"
if out=$(cargo publish --locked 2>&1); then
printf '%s\n' "$out"
else
printf '%s\n' "$out"
if printf '%s' "$out" | grep -qiE "already (been )?uploaded|already exists"; then
echo " amont-agent $VERSION was already published — continuing"
else
echo "::error::publishing amont-agent failed"
exit 1
fi
fi
echo "### Published to crates.io" >> "$GITHUB_STEP_SUMMARY"
echo "\`cargo install amont-agent\` — v$VERSION" >> "$GITHUB_STEP_SUMMARY"
publish-tap:
name: publish to the homebrew tap
needs: [guard, publish]
if: github.ref_type == 'tag'
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: read
env:
VERSION: ${{ needs.guard.outputs.version }}
steps:
- uses: actions/checkout@v4
- name: Download the published checksums
env:
GH_TOKEN: ${{ github.token }}
run: gh release download "v$VERSION" --pattern SHA256SUMS
- name: Clone the tap
env:
TAP_DEPLOY_KEY: ${{ secrets.TAP_DEPLOY_KEY }}
run: |
set -euo pipefail
mkdir -p ~/.ssh
printf '%s\n' "$TAP_DEPLOY_KEY" > ~/.ssh/tap_key
chmod 600 ~/.ssh/tap_key
ssh-keyscan github.com >> ~/.ssh/known_hosts 2>/dev/null
echo "GIT_SSH_COMMAND=ssh -i ~/.ssh/tap_key -o IdentitiesOnly=yes" >> "$GITHUB_ENV"
GIT_SSH_COMMAND="ssh -i ~/.ssh/tap_key -o IdentitiesOnly=yes" \
git clone --depth 1 git@github.com:fredericrous/homebrew-tap.git tap
- name: Rewrite the formula
run: |
set -euo pipefail
python3 scripts/bump-tap.py "$VERSION" SHA256SUMS tap/Formula/amont-agent.rb
ruby -c tap/Formula/amont-agent.rb
- name: Push
run: |
set -euo pipefail
cd tap
if git diff --quiet; then
echo "the tap already carries $VERSION — nothing to publish"
exit 0
fi
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add Formula/amont-agent.rb
git commit -m "chore: amont-agent $VERSION"
git push origin HEAD
echo "### Published to the homebrew tap" >> "$GITHUB_STEP_SUMMARY"
echo "\`brew upgrade fredericrous/tap/amont-agent\` — v$VERSION" >> "$GITHUB_STEP_SUMMARY"
verify-brew:
name: brew can install what was published
needs: [guard, publish-tap]
if: github.ref_type == 'tag'
runs-on: macos-latest
timeout-minutes: 20
permissions:
contents: read
env:
VERSION: ${{ needs.guard.outputs.version }}
steps:
- name: Install from the tap
run: |
set -euo pipefail
brew tap fredericrous/tap
brew install fredericrous/tap/amont-agent
- name: The installed binary is the version just published
run: |
set -euo pipefail
amont-agent --version | tee /tmp/v
grep -qx "amont-agent $VERSION" /tmp/v
- name: The installed guard still says no
run: |
set -euo pipefail
out=$(amont-agent check 'git push origin main 2>&1 | tail -5')
printf '%s\n' "$out"
printf '%s' "$out" | grep -q 'pipe-to-tail'
- name: brew test
run: brew test fredericrous/tap/amont-agent
- name: Say so
run: |
echo "### brew install verified" >> "$GITHUB_STEP_SUMMARY"
echo "\`brew install fredericrous/tap/amont-agent\` produces v$VERSION" >> "$GITHUB_STEP_SUMMARY"