Skip to content
Merged
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
29 changes: 29 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
59 changes: 59 additions & 0 deletions .github/workflows/tap-token-canary.yml
Original file line number Diff line number Diff line change
@@ -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"
4 changes: 4 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
99 changes: 99 additions & 0 deletions docs/RELEASING.md
Original file line number Diff line number Diff line change
@@ -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.
135 changes: 135 additions & 0 deletions scripts/release.sh
Original file line number Diff line number Diff line change
@@ -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}"