diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8af191d..876923d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -12,6 +12,35 @@ jobs: goreleaser: runs-on: ubuntu-latest steps: + # Fail before anything is published, not after. + # + # goreleaser pushes the Homebrew formula LAST, so a token that cannot write + # the tap produces the worst possible outcome: the GitHub release and every + # artifact go out, the formula stays on the previous version, and the run + # ends red with users silently stuck on the old release. That is exactly + # what v0.1.1 did, and the formula had to be published by hand. + # + # The tap is a PUBLIC repo, so an unauthorised token still gets 200 and the + # full repo JSON back — only the `permissions` block is missing. Absence is + # therefore the signal, and `.permissions.push` is the thing to assert. + - name: verify the tap token can still write + env: + TAP_GITHUB_TOKEN: ${{ secrets.TAP_GITHUB_TOKEN }} + run: | + if [ -z "$TAP_GITHUB_TOKEN" ]; then + echo "::error::TAP_GITHUB_TOKEN is not set on this repository." + exit 1 + fi + push=$(curl -sS -H "Authorization: Bearer $TAP_GITHUB_TOKEN" \ + https://api.github.com/repos/Open-Email/homebrew-tap \ + | jq -r '.permissions.push // "absent"') + if [ "$push" != "true" ]; then + echo "::error::TAP_GITHUB_TOKEN cannot write Open-Email/homebrew-tap (permissions.push=$push)." + echo "::error::Fine-grained PATs expire (30 days by default). Regenerate with resource owner Open-Email, homebrew-tap selected, Contents: Read and write." + exit 1 + fi + echo "tap token verified writable" + - uses: actions/checkout@v5 with: fetch-depth: 0 diff --git a/.github/workflows/tap-token-canary.yml b/.github/workflows/tap-token-canary.yml new file mode 100644 index 0000000..4f5e1e3 --- /dev/null +++ b/.github/workflows/tap-token-canary.yml @@ -0,0 +1,59 @@ +name: tap-token-canary + +# Releases are rare; the token that publishes them expires on its own schedule. +# +# TAP_GITHUB_TOKEN is a PAT with write access to Open-Email/homebrew-tap, and +# fine-grained PATs expire after 30 days unless told otherwise. Nothing observes +# that until the next release tries to push a formula — which is how v0.1.1 +# shipped a GitHub release with a stale tap behind it. This checks weekly, so an +# expired token is a failed cron on a Monday morning rather than a half-finished +# release. +# +# A failure here publishes nothing and breaks nothing. It means: regenerate the +# token before you next tag. + +on: + schedule: + # Mondays, 08:00 UTC — before the week's work, not during a release. + - cron: "0 8 * * 1" + workflow_dispatch: + +permissions: + contents: read + +jobs: + check: + runs-on: ubuntu-latest + steps: + - name: can the tap token still write? + env: + TAP_GITHUB_TOKEN: ${{ secrets.TAP_GITHUB_TOKEN }} + run: | + if [ -z "$TAP_GITHUB_TOKEN" ]; then + echo "::error::TAP_GITHUB_TOKEN is not set — the next release will not update Homebrew." + exit 1 + fi + + # Does it authenticate at all? An expired or revoked token answers 401 + # here, which is a different fix from a token that works but was never + # granted the tap. + code=$(curl -sS -o /tmp/who.json -w '%{http_code}' \ + -H "Authorization: Bearer $TAP_GITHUB_TOKEN" https://api.github.com/user) + if [ "$code" != "200" ]; then + echo "::error::TAP_GITHUB_TOKEN does not authenticate (HTTP $code: $(jq -r '.message // "?"' /tmp/who.json)). It has most likely expired — regenerate it." + exit 1 + fi + + # The tap is public, so a token with no grant still gets 200 and the + # whole repo object; only `permissions` is missing. Absence is the + # signal, not the status code. + push=$(curl -sS -H "Authorization: Bearer $TAP_GITHUB_TOKEN" \ + https://api.github.com/repos/Open-Email/homebrew-tap \ + | jq -r '.permissions.push // "absent"') + if [ "$push" != "true" ]; then + echo "::error::TAP_GITHUB_TOKEN authenticates but cannot write Open-Email/homebrew-tap (permissions.push=$push)." + echo "::error::Regenerate with resource owner Open-Email, homebrew-tap among the selected repositories, and Contents: Read and write." + exit 1 + fi + + echo "tap token is valid and can write Open-Email/homebrew-tap" diff --git a/Makefile b/Makefile index e28f4c3..cebf611 100644 --- a/Makefile +++ b/Makefile @@ -39,6 +39,10 @@ clean: ## Remove build artifacts snapshot: ## Dry-run a full goreleaser build (needs goreleaser installed) goreleaser release --snapshot --clean +release: ## Cut a release: make release VERSION=v0.2.1 [MESSAGE="..."] + @test -n "$(VERSION)" || { echo "usage: make release VERSION=vX.Y.Z [MESSAGE=\"...\"]"; exit 1; } + ./scripts/release.sh $(VERSION) $(if $(MESSAGE),-m "$(MESSAGE)") + sync-spec: ## Refresh the vendored openapi.snapshot.json from core ($(CORE_DIR); run `npm run spec` there first) @test -f $(CORE_DIR)/openapi.snapshot.json || { echo "not found: $(CORE_DIR)/openapi.snapshot.json (set CORE_DIR or run 'npm run spec' in core)"; exit 1; } cp $(CORE_DIR)/openapi.snapshot.json ./openapi.snapshot.json diff --git a/docs/RELEASING.md b/docs/RELEASING.md new file mode 100644 index 0000000..ae7835d --- /dev/null +++ b/docs/RELEASING.md @@ -0,0 +1,99 @@ +# Releasing + +``` +make release VERSION=v0.2.1 +``` + +That is the whole procedure. Everything below is why it has the shape it does — +read it if the release fails, or before changing it. + +## What runs + +`scripts/release.sh` refuses, tags, watches, then verifies: + +1. **Refuses** unless you are on `main`, the tree is clean, local matches + `origin/main`, the tag is unused, and CI is green **for this exact commit**. +2. **Tags** annotated and pushes, which is the only trigger the release workflow + has (`on: push: tags: v*`). +3. **Watches** the workflow and fails loudly if it does. +4. **Verifies what a user gets**: the release has assets, the Homebrew formula + reports the new version, and one real asset's SHA-256 matches the formula. + +`goreleaser` does the publishing: cross-compiled binaries, shell completions, +deb/rpm, the GitHub release with a changelog generated from commit subjects, and +the Homebrew formula pushed to `Open-Email/homebrew-tap`. + +## Why each check exists + +None of these are hypothetical; each one is a failure that already happened. + +**CI matched on the commit SHA, not "the latest run".** v0.2.0 was nearly cut +from a `main` whose CI was red — six login tests failed on the Linux runner and +passed on macOS, because `browserLikelyAvailable` reads `DISPLAY` on Linux and +short-circuits to true on darwin. A release cut then would have shipped from a +tree nobody had actually seen pass. Matching on SHA also stops a green run for an +*older* commit vouching for this one. + +**The tap token is verified before anything publishes.** goreleaser pushes the +formula **last**, so a token that cannot write the tap gives the worst outcome +available: release and artifacts go out, the formula stays on the old version, +the run ends red, and users silently keep installing the previous release. That +is precisely what v0.1.1 did, and its formula had to be published by hand. The +check now runs as the first step of the release job, so a bad token costs two +seconds and publishes nothing. + +**The post-publish verification is not ceremony.** A green workflow is not proof +that the release is usable: goreleaser can succeed while the formula's checksums +disagree with its assets, and that failure appears only in somebody's +`brew install`. The script downloads one asset and compares. + +## The token + +`TAP_GITHUB_TOKEN` (repo secret on `Open-Email/cli`) is a PAT that can write +`Open-Email/homebrew-tap`. To regenerate: + +- **Resource owner: `Open-Email`** — not a personal account. A user-owned token + cannot reach org repositories and fails with + `403 Resource not accessible by personal access token`, which reads like a + scope problem and is not one. +- Repository access: only `Open-Email/homebrew-tap`. +- Permissions: **Contents: Read and write**. Nothing else. +- Expiry: fine-grained PATs default to **30 days**. Choose a long one + deliberately — this has already expired once between releases. +- If the org gates tokens, an owner must approve it under + Settings → Personal access tokens → Pending requests. + +Then `gh secret set TAP_GITHUB_TOKEN --repo Open-Email/cli`. + +`.github/workflows/tap-token-canary.yml` checks it every Monday, so the next +expiry is a failed cron rather than a half-finished release. Run it on demand +with `gh workflow run tap-token-canary.yml --repo Open-Email/cli`. + +**Diagnosing the token.** The tap is a *public* repo, so a token with no grant +still receives `200` and the full repository JSON — only the `permissions` block +is missing. Absence is the signal, which is why the checks assert +`.permissions.push == true` rather than a status code. And public visibility +never removed the need for a token: reads are anonymous, writes are not. + +## Both repos must stay public + +`homebrew-tap` so `brew tap` can clone it anonymously, and `Open-Email/cli` +because the formula's `url` fields point at its release assets — a public tap in +front of private assets simply fails one step later. A private tap is possible, +but then every user must authenticate to install, which suits an internal tool +and not a published CLI. + +## Versioning + +`ldflags` injects the tag into `internal/cli.Version`, so nothing in the source +needs bumping — `openemail version` reports whatever was tagged. Untagged local +builds report `0.1.0-dev`. + +Under 0.x, a new user-visible capability is a minor bump (browser login was +`v0.1.1 → v0.2.0`); fixes are patches. + +## Dry run + +`make snapshot` builds everything locally and publishes nothing. Worth doing +after changing `.goreleaser.yml`, since most of that config is only exercised +during a real release. diff --git a/scripts/release.sh b/scripts/release.sh new file mode 100755 index 0000000..10dae0a --- /dev/null +++ b/scripts/release.sh @@ -0,0 +1,135 @@ +#!/usr/bin/env bash +# +# One command to cut a release: check, tag, push, watch, verify. +# +# The publishing itself has always been automated — goreleaser builds the +# artifacts, writes the GitHub release and pushes the Homebrew formula. What was +# not automated is everything AROUND it, which is where both previous releases +# went wrong: v0.1.1 published a release and then failed to update the tap +# (leaving users on the old formula with a green-looking tag), and v0.2.0 was +# very nearly cut from a main whose CI was red. +# +# So this refuses before it tags, and verifies after it publishes. The checks +# are the point; the tagging is three lines. +# +# Usage: scripts/release.sh v0.2.1 [-m "optional tag message"] +set -euo pipefail + +REPO="Open-Email/cli" +TAP="Open-Email/homebrew-tap" +BRANCH="main" + +die() { printf '\033[31m✗\033[0m %s\n' "$*" >&2; exit 1; } +ok() { printf '\033[32m✓\033[0m %s\n' "$*"; } +say() { printf '\n\033[1m%s\033[0m\n' "$*"; } + +VERSION="${1:-}" +[ -n "$VERSION" ] || die "usage: $0 vX.Y.Z [-m message]" +shift +MESSAGE="" +while [ $# -gt 0 ]; do + case "$1" in + -m) MESSAGE="${2:-}"; shift 2 ;; + *) die "unknown argument: $1" ;; + esac +done + +[[ "$VERSION" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]] \ + || die "version must look like v1.2.3 (got '$VERSION')" + +say "Pre-flight" + +command -v gh >/dev/null || die "gh is required" +gh auth status >/dev/null 2>&1 || die "gh is not authenticated (gh auth login)" +ok "gh authenticated" + +branch=$(git rev-parse --abbrev-ref HEAD) +[ "$branch" = "$BRANCH" ] || die "on '$branch'; releases are cut from '$BRANCH'" +ok "on $BRANCH" + +[ -z "$(git status --porcelain)" ] || die "working tree is dirty — commit or stash first" +ok "working tree clean" + +git fetch -q origin "$BRANCH" +local_sha=$(git rev-parse HEAD) +remote_sha=$(git rev-parse "origin/$BRANCH") +[ "$local_sha" = "$remote_sha" ] \ + || die "local $BRANCH ($(git rev-parse --short HEAD)) differs from origin ($(git rev-parse --short origin/$BRANCH)) — pull or push first" +ok "in sync with origin/$BRANCH" + +if git rev-parse -q --verify "refs/tags/$VERSION" >/dev/null \ + || git ls-remote --exit-code --tags origin "refs/tags/$VERSION" >/dev/null 2>&1; then + die "tag $VERSION already exists — pick another version" +fi +ok "$VERSION is unused" + +# The check that would have stopped v0.2.0 being cut from a broken tree. Matched +# on the SHA, not just "the latest run", so a green run for an OLDER commit +# cannot vouch for this one. +say "CI on $local_sha" +ci=$(gh run list --repo "$REPO" --workflow=ci.yml --branch "$BRANCH" --limit 20 \ + --json headSha,status,conclusion \ + --jq "[.[] | select(.headSha==\"$local_sha\")] + | if length == 0 then \"missing -\" else \"\(.[0].status) \(.[0].conclusion // \"-\")\" end") +read -r status concl <<<"$ci" +[ "$status" != "missing" ] || die "no CI run found for $local_sha — push and let CI finish first" +[ "$status" = "completed" ] || die "CI for this commit is '$status' — wait for it" +[ "$concl" = "success" ] || die "CI for this commit concluded '$concl' — fix it before releasing" +ok "CI green for this exact commit" + +say "Tagging $VERSION" +[ -n "$MESSAGE" ] || MESSAGE="$VERSION" +git tag -a "$VERSION" -m "$MESSAGE" +git push -q origin "$VERSION" +ok "pushed tag $VERSION" + +say "Release workflow" +sleep 10 +run_id="" +for _ in $(seq 1 12); do + run_id=$(gh run list --repo "$REPO" --workflow=release.yml --limit 5 \ + --json databaseId,headBranch --jq "[.[] | select(.headBranch==\"$VERSION\")][0].databaseId // empty") + [ -n "$run_id" ] && break + sleep 5 +done +[ -n "$run_id" ] || die "release workflow did not start for $VERSION — check Actions" +echo " watching run $run_id …" +gh run watch "$run_id" --repo "$REPO" --exit-status >/dev/null \ + || die "release workflow FAILED — see: gh run view $run_id --repo $REPO --log-failed" +ok "release workflow succeeded" + +# Everything below is what was checked by hand after v0.2.0. A green workflow is +# not proof: goreleaser can publish a release and still leave the tap stale, and +# a formula whose checksums do not match its assets fails only in `brew install`. +say "Verifying what users will actually get" + +num=${VERSION#v} + +assets=$(gh release view "$VERSION" --repo "$REPO" --json assets --jq '.assets | length' 2>/dev/null || echo 0) +[ "$assets" -gt 0 ] || die "no GitHub release assets for $VERSION" +ok "release published with $assets assets" + +formula_version=$(curl -fsSL "https://raw.githubusercontent.com/${TAP}/main/openemail.rb" | sed -n 's/^ version "\(.*\)"/\1/p') +[ "$formula_version" = "$num" ] \ + || die "tap formula is at '$formula_version', expected '$num' — the tap push did not land (check TAP_GITHUB_TOKEN)" +ok "tap formula updated to $num" + +# One real download, one real checksum: the failure that shows up as a broken +# `brew install` rather than a red build. +host_os=$(uname -s | tr '[:upper:]' '[:lower:]'); [ "$host_os" = "darwin" ] || host_os=linux +host_arch=$(uname -m); case "$host_arch" in x86_64) host_arch=amd64 ;; aarch64|arm64) host_arch=arm64 ;; esac +asset="openemail_${num}_${host_os}_${host_arch}.tar.gz" +tmp=$(mktemp -d); trap 'rm -rf "$tmp"' EXIT +curl -fsSL -o "$tmp/a.tgz" "https://github.com/${REPO}/releases/download/${VERSION}/${asset}" \ + || die "could not download $asset" +if command -v sha256sum >/dev/null; then actual=$(sha256sum "$tmp/a.tgz" | awk '{print $1}') +else actual=$(shasum -a 256 "$tmp/a.tgz" | awk '{print $1}'); fi +expected=$(curl -fsSL "https://raw.githubusercontent.com/${TAP}/main/openemail.rb" \ + | grep -A2 "${asset}" | sed -n 's/.*sha256 "\(.*\)".*/\1/p' | head -1) +[ -n "$expected" ] || die "no sha256 for $asset in the formula" +[ "$actual" = "$expected" ] || die "checksum mismatch for $asset — formula $expected, asset $actual" +ok "checksum matches for $asset" + +say "$VERSION is live" +echo " brew upgrade open-email/tap/openemail" +echo " https://github.com/${REPO}/releases/tag/${VERSION}"