Skip to main content

Release

Versioning

Semver. Pre-1.0 means breaking changes are allowed in minor versions; document them in the changelog. Prefer correcting the product model over maintaining accidental behavior for the current single controlled adopter. Still preserve or explicitly migrate durable project-owned data and policy; pre-1.0 is not permission for silent loss or downgrade.

  • v0.x.y — pre-stable. CLI surface and config schema may change in x.
  • v1.0.0 — schema and CLI surface frozen. Migrations forward-compatible.

Tags

  • Tag format: vX.Y.Z. No v0.0.1-rc1 shenanigans during week 1.
  • Tag from main only.
  • Run the production release rehearsal before creating the remote tag.
  • Annotated tags must bind the approved rehearsal run:
git tag -a v0.2.5 -m $'v0.2.5\n\nfairway-rehearsal-run: <run-id>'

See Release Candidate Promotion.

Changelog

  • CHANGELOG.md at repo root, kept in Keep a Changelog format.
  • One section per released version, plus an Unreleased section at the top.
  • Each PR touches the Unreleased section.

User-facing release highlights

  • docs/release-highlights.md contains the curated GitHub Release highlights for the next tag.
  • Keep it to at most three bullets, written for readers who do not follow the repository history.
  • The release owner drafts the bullets from docs/release-notes.md, CHANGELOG.md, and the release-run assessment.
  • Governance approves the wording and ops approves the release workflow posture before tagging.
  • The release workflow loads the file into FAIRWAY_RELEASE_HIGHLIGHTS, and GoReleaser prepends it to the generated changelog under ## Highlights. Generated changelog detail remains below ## Changelog.

Release preparation split

Fairway release work is split into separate tracked tasks so preparation, dashboard lifecycle validation, and publishing do not blur together:

  • Release preparation updates release notes, highlights, distribution posture, and the release-readiness assessment. It may name the candidate version and source SHA, but it does not tag, publish, restart dashboards, or declare the release complete.
  • Dashboard lifecycle/version-readback work verifies the restart commands and status/version surfaces operators need before a shared dashboard is restarted with a newly built binary.
  • Release publishing cuts the reviewed tag, builds and smoke-verifies artifacts, publishes or stages documentation release content, restarts the configured dashboards when authorized, and records version/readback evidence.

Use this split for release tasks such as FW-218, FW-219, and FW-220. A clean release-prep assessment is evidence for publish readiness, not a substitute for the publish task.

goreleaser

  • Config at .goreleaser.yaml.
  • CI runs goreleaser check so release config drift is caught before tags.
  • Targets: darwin/amd64, darwin/arm64, linux/amd64, linux/arm64.
  • Archives: fairway_<version>_<os>_<arch>.tar.gz.
  • Checksums: fairway_<version>_checksums.txt.
  • GitHub Release drafts are created from fairway-run/fairway only after the signed release assurance bundle verifies the exact candidate archives.
  • The macOS CLI binaries are signed with native codesign and submitted with native notarytool from the macOS release runner before Homebrew publishing. GoReleaser OSS still drives the signed candidate build, archives, and checksums. Fairway gates the exact archives before GitHub draft creation; the reviewed publish step updates the cask from those verified digests. GoReleaser Pro is not required unless Fairway later ships macOS app bundles, DMGs, or PKGs.

CI release flow

  1. Dispatch Release Rehearsal with the proposed version and exact pushed main SHA.
  2. The rehearsal runs tests, GoReleaser, signing, notarization, candidate binary smoke, and signed assurance verification before uploading one immutable promotion packet.
  3. Create and push an annotated tag containing the successful rehearsal run id.
  4. The tag workflow verifies the run identity and exact packet, then creates a GitHub draft from those assets without rebuilding.
  5. The separately reviewed publish step makes the draft public and updates the Homebrew cask from the verified archive digests.
  6. No automatic publishing to other package registries until v1.0.

GitHub Actions runtime posture

Fairway workflows use Node 24-capable GitHub Action majors to avoid Node.js 20 runtime deprecation warnings:

  • actions/checkout@v5
  • actions/setup-go@v6
  • actions/setup-node@v5
  • actions/upload-artifact@v6
  • goreleaser/goreleaser-action@f06c13b6b1a9625abc9e6e439d9c05a8f2190e94 (v7.2.3)

GoReleaser action invocations are commit-pinned because the rehearsal build step receives notarization authority. Updating the action requires an explicit review of the new upstream commit before changing the pin.

The release workflow must pass goreleaser check before the next tag. The Docs Portal workflow must still run npm ci and npm run build; the deploy step is validated on pushed main with the Cloudflare secrets present. Hosted GitHub runners satisfy the required action runner version; self-hosted runners must be kept current enough for Node 24 action runtimes before using these workflow versions.

Release repositories

  • Source/release repo: fairway-run/fairway.
  • Homebrew tap repo: fairway-run/homebrew-tap.
  • Tap command: brew tap fairway-run/tap.
  • Install command: brew install --cask fairway.

The tap repository is separate from the source repository. The release workflow must use a dedicated tap token; the default GITHUB_TOKEN is not enough for cross-repository tap updates.

Before the first release, initialize the tap repository with a main branch so GoReleaser can push the first generated cask:

tmpdir=$(mktemp -d /tmp/fairway-homebrew-tap.XXXXXX)
cd "$tmpdir"
git init
git branch -M main
mkdir -p Casks
touch Casks/.gitkeep
cat > README.md <<'EOF'
# Homebrew Tap for Fairway

brew tap fairway-run/tap
brew install --cask fairway
EOF
git add README.md Casks/.gitkeep
git commit -m "Initialize Homebrew tap"
git remote add origin git@github.com:fairway-run/homebrew-tap.git
git push -u origin main

Required release secrets

Set these GitHub Actions secrets on fairway-run/fairway before cutting a release tag:

SecretPurpose
HOMEBREW_TAP_GITHUB_TOKENFine-scoped token with write access to fairway-run/homebrew-tap.
MACOS_SIGN_P12Base64-encoded Developer ID Application .p12 certificate.
MACOS_SIGN_PASSWORDPassword for the .p12 certificate.
MACOS_CODESIGN_IDENTITYDeveloper ID Application identity fingerprint or exact name used by native codesign.
MACOS_NOTARY_KEYBase64-encoded App Store Connect .p8 API key.
MACOS_NOTARY_KEY_IDApp Store Connect API key id.
MACOS_NOTARY_ISSUER_IDApp Store Connect issuer id.
FAIRWAY_RELEASE_ASSURANCE_SIGNING_KEYPrivate Ed25519 key used only while building signed release assurance.

Set FAIRWAY_RELEASE_ASSURANCE_PUBLIC_KEY as a separately pinned GitHub Actions variable. Promotion verifies assurance with that public key and does not receive the private signing key.

For local/manual signing experiments, .apple-app-specific.env may hold local Apple credential material. It is ignored by git and must never be committed, printed in logs, pasted into task evidence, or shared with any consumer project's domain or deployment credentials. CI should use the App Store Connect API key secrets above.

macOS signing and notarization baseline

Fairway uses Developer ID signing and native Apple notarization for macOS CLI artifacts. The current release baseline is:

  • Certificate type: Developer ID Application.
  • Certificate chain: Developer ID Application -> Developer ID Certification Authority -> Apple Root CA.
  • Team identifier: Apple Developer team id for the Fairway release account.
  • Hardened runtime: enabled by the release hook with codesign --options runtime.
  • Notarization auth: App Store Connect API key, not Apple ID password auth.

GoReleaser Pro is only needed if Fairway later ships native app bundles, macOS DMGs, or macOS PKGs. The first release distributes a CLI binary, so the OSS release path plus native macOS build hooks is sufficient.

For the first public release, use the working Developer ID certificate from the previous Sub-CA. The G2 certificate is available, but local validation showed that codesign will not build the G2 chain until the Developer ID G2 intermediate is trusted from the System chain. Previous Sub-CA certificates created after February 1, 2022 expire on February 1, 2027; switch to G2 well before that date and require a passing local/CI codesign --verify --strict check before publishing.

Local release material lives under ignored paths only:

Local pathPurpose
.release-certs/developerID_application_identities.p12Local Developer ID signing bundle.
.release-certs/developerID_application_identities.p12.base64Value source for MACOS_SIGN_P12.
.release-certs/macos-sign-p12-password.localValue source for MACOS_SIGN_PASSWORD.
.release-certs/AuthKey_<KEY_ID>.p8App Store Connect notary API key.
.release-certs/AuthKey_<KEY_ID>.p8.base64Value source for MACOS_NOTARY_KEY.
.apple-app-specific.envLocal-only Apple release environment values.

Back up these files outside git, for example under the operator's private iCloud project folder. Do not place certificate passwords, private keys, API keys, issuer ids, or key ids in public docs or task evidence. Do not use dist/certs/ for durable release credentials because GoReleaser cleans dist/.

Set release secrets without echoing values:

gh secret set MACOS_SIGN_P12 \
--repo fairway-run/fairway \
< .release-certs/developerID_application_identities.p12.base64

gh secret set MACOS_SIGN_PASSWORD \
--repo fairway-run/fairway \
< .release-certs/macos-sign-p12-password.local

gh secret set MACOS_CODESIGN_IDENTITY \
--repo fairway-run/fairway \
--body "$MACOS_CODESIGN_IDENTITY"

gh secret set MACOS_NOTARY_KEY \
--repo fairway-run/fairway \
< .release-certs/AuthKey_<KEY_ID>.p8.base64

gh secret set MACOS_NOTARY_KEY_ID \
--repo fairway-run/fairway \
--body "$MACOS_NOTARY_KEY_ID"

gh secret set MACOS_NOTARY_ISSUER_ID \
--repo fairway-run/fairway \
--body "$MACOS_NOTARY_ISSUER_ID"

gh secret set HOMEBREW_TAP_GITHUB_TOKEN \
--repo fairway-run/fairway

Local signing smoke:

tmpdir=$(mktemp -d /tmp/fairway-sign-test.XXXXXX)
go build -o "$tmpdir/fairway" ./cmd/fairway
codesign --force --timestamp --options runtime \
--sign "<Developer ID Application identity>" \
"$tmpdir/fairway"
codesign --verify --strict --verbose=4 "$tmpdir/fairway"
codesign -dv --verbose=4 "$tmpdir/fairway"

For archive notarization smoke tests, sign the binary, zip the artifact, then submit the zip to notarytool with the App Store Connect API key:

xcrun notarytool submit fairway.zip \
--key .release-certs/AuthKey_<KEY_ID>.p8 \
--key-id "$MACOS_NOTARY_KEY_ID" \
--issuer "$MACOS_NOTARY_ISSUER_ID" \
--wait

stapler cannot staple a zip archive. For the current tar/zip CLI distribution, an accepted notarization result is the expected release signal. Use a .pkg or .dmg if Fairway later needs stapled offline verification.

First Homebrew publish

Run these checks before tagging:

git status --short
go test ./...
go vet ./...
goreleaser check
(cd website && npm run build)
go run ./cmd/fairway workflow check \
--mode deploy \
--require-clean \
--require-pushed

Create one release-run task and packet for each meaningful release attempt:

fairway packet release-run FW-REL-012 \
--version v0.1.2 \
--tag v0.1.2 \
--source-sha "$(git rev-parse HEAD)" \
--release-notes docs/release-notes.md \
--changelog-state "CHANGELOG.md has v0.1.2 section" \
--ci-status pass \
--docs-status pass \
--signing-status pass \
--notary-status pass \
--release-url "https://github.com/fairway-run/fairway/releases/tag/v0.1.2" \
--homebrew-tap-commit <tap-commit-sha> \
--verification-command "go test ./..." \
--verification-command "go vet ./..." \
--verification-command "goreleaser check" \
--verification-command "brew fetch --cask --force fairway-run/tap/fairway"

Generate a Fairway provenance bundle before release review. The bundle should name the release source SHA, included Fairway task ids, evidence summaries, review gates, CI/CD checks, and known waivers, without embedding raw prompts, private transcripts, raw tool bodies, generated-content dumps, credentials, or secrets:

fairway provenance report --since 168h --format json > artifacts/fairway-provenance-v0.1.2.json
fairway provenance report --since 168h --format markdown > artifacts/fairway-provenance-v0.1.2.md

The JSON bundle can be attached to release review evidence, archived with the release packet, or referenced from release notes. Public release notes may link to a reviewed public bundle or summarize its digest/reference, but they should not expose internal-only URLs or sensitive artifact paths. This is the Fairway control-plane input for future SLSA or in-toto attestations; it does not require or replace those systems in the first implementation.

Push the clean candidate commit, then dispatch the production rehearsal before creating a remote tag:

git fetch --all --tags
git status --short --branch
source_sha=$(git rev-parse HEAD)
gh workflow run release-rehearsal.yml \
--repo fairway-run/fairway \
--ref main \
-f version=v0.2.5 \
-f source_sha="$source_sha"
gh run list \
--repo fairway-run/fairway \
--workflow release-rehearsal.yml \
--limit 5

After the rehearsal succeeds, use its numeric run id in the annotated tag. The tag promotion workflow rejects lightweight tags, missing or duplicate run bindings, runs for another SHA, and expired or mutated candidate packets:

rehearsal_run=<successful-run-id>
git tag -a v0.2.5 \
-m $'v0.2.5\n\nfairway-rehearsal-run: '"$rehearsal_run"
git push fairway-run v0.2.5
gh run list --repo fairway-run/fairway --workflow release.yml --limit 5

If the tag-push workflow fails before publication because the runner cannot resolve remote tag metadata, fix the verifier on main without moving or recreating the immutable tag. Then dispatch the recovery path against the existing tag:

gh workflow run release.yml \
--repo fairway-run/fairway \
--ref main \
-f version=v0.2.5

The recovery workflow checks out the existing tag target, verifies the remote annotated tag object, source commit, rehearsal binding, candidate packet, and signed assurance, and revalidates the tag object and commit again immediately before draft creation. It must not accept a lightweight, moved, recreated, or missing tag and must never rewrite the tag as part of recovery.

The GitHub Release is intentionally created as a draft. Review artifacts, checksums, signing/notarization logs, and the proposed Homebrew cask digests before publishing the draft and updating the tap.

Do not treat the release as Homebrew-ready while the GitHub Release is still a draft. The assurance-gated workflow intentionally does not mutate the Homebrew tap. The reviewed publish step updates the cask only after the draft and its verified assets have been inspected. Cask URLs point at releases/download/vX.Y.Z/... and return 404 until the release is public; the release-run checklist should classify a premature cask update as failed verification:

Homebrew cask version == tag, but GitHub release is draft or asset URL is 404.
Action: publish the reviewed release draft, then verify asset URLs and brew fetch.

Verify Homebrew after the cask update lands:

brew untap fairway-run/tap || true
brew tap fairway-run/tap
brew install --cask fairway
fairway help
brew uninstall --cask fairway

For a lower-impact verification that does not install Fairway, update the tap cache and fetch the cask:

tapdir=$(brew --repository fairway-run/tap)
git -C "$tapdir" fetch origin main --prune
git -C "$tapdir" reset --hard origin/main
brew info --cask fairway-run/tap/fairway
brew fetch --cask --force fairway-run/tap/fairway

The expected result is:

  • GitHub release is not draft.
  • Release assets include checksums and all target archives.
  • Asset URLs return 200 after redirects.
  • Homebrew cask reports the new version.
  • brew fetch --cask --force fairway-run/tap/fairway succeeds.

Record the observed release state with the release verification guard:

fairway release verify \
--version v0.1.2 \
--tag v0.1.2 \
--source-sha "$(git rev-parse HEAD)" \
--release-notes docs/release-notes.md \
--changelog CHANGELOG.md \
--ci-status pass \
--docs-status pass \
--signing-status pass \
--notary-status pass \
--release-state public \
--release-url "https://github.com/fairway-run/fairway/releases/tag/v0.1.2" \
--asset "https://github.com/fairway-run/fairway/releases/download/v0.1.2/fairway_v0.1.2_checksums.txt=200" \
--homebrew-version 0.1.2 \
--homebrew-tap-commit <tap-commit-sha> \
--brew-fetch-status pass \
--provenance-bundle artifacts/fairway-provenance-v0.1.2.json \
--verification-command "brew fetch --cask --force fairway-run/tap/fairway"

fairway release verify warns when no provenance bundle is named. If --provenance-bundle is provided, the path must exist; otherwise release verification fails. The guard also warns when the bundle does not mention the release version or supplied source SHA, so reviewers can catch stale provenance before tagging or publishing.

Git tags include the v prefix (v0.1.2), while Homebrew cask versions normally omit it (0.1.2). fairway release verify normalizes that prefix for the Homebrew comparison but still fails real semantic mismatches.

The v0.1.2 lesson is explicit: if --release-state draft while --homebrew-version matches the release tag after prefix normalization, fairway release verify fails. Publish the reviewed GitHub release draft first, then verify asset URLs and brew fetch before treating the Homebrew cask as usable.

If the cask publish fails, fix the tap or release config and rerun the release workflow only when the generated cask will point to the same immutable tag and checksums. If the released artifact itself is wrong, yank and cut a new version; never reuse a version number.

Sovereign release assurance bundle

The manually dispatched release-rehearsal workflow first runs GoReleaser with --skip=publish, then builds and verifies one fairway.release-assurance-manifest.v1 package over those exact candidate archives. Repository configuration must provide secret FAIRWAY_RELEASE_ASSURANCE_SIGNING_KEY, separately pinned variable FAIRWAY_RELEASE_ASSURANCE_PUBLIC_KEY, and policy identifier FAIRWAY_RELEASE_POLICY_VERSION.

The workflow pins the official Anchore Syft download action v0.24.0 by commit with Syft v1.46.0, plus go-licenses v1.6.0 and govulncheck v1.1.4. It records build environment/tool versions and fails if required archives or evidence are absent. A govulncheck finding stops the automatic no-findings path; it requires a separate reviewed vulnerability disposition rather than silently publishing an empty VEX.

No tag, GitHub release, or Homebrew side effect occurs when rehearsal fails. After the final annotated tag binds an approved run, the promotion workflow downloads and verifies the exact rehearsal packet and signed assurance before creating a draft release. It does not rebuild, sign, or notarize. A failed assurance or promotion step therefore leaves no public release or tap mutation to clean up. Publishing the draft, updating Homebrew, and authorizing deployment remain separately reviewed actions.

Offline verification requires exact expected version, source, builder, policy, and pinned public key. Measured SLSA fields do not assign a level. Hermeticity and reproducibility remain false until separate repeatable evidence proves them.

Sovereign offline distribution

After current and rollback release-assurance packages verify, an explicitly reviewed release task may run scripts/release/build_offline_distribution_bundle.sh. The resulting signed offline distribution includes both complete assurance packages, all four supported binary and standalone-verifier targets, local documentation, configuration and deployment baselines, and fixed verify/install/rollback scripts. The builder requires the current package source SHA to equal checked out HEAD; it does not fetch release archives or publish the result.

fairway release offline verify and the standalone fairway-offline-verify require a separately pinned Ed25519 public key and exact current and rollback version/source/builder/policy identity. Run the disconnected lifecycle rehearsal and retain its version, path, backup, state, digest, rollback, and cleanup evidence before attaching the bundle to a release.

The verifier binary has its own bootstrap trust requirement: obtain it or its approved digest through the customer software-intake boundary before using the copy inside removable media. Bundle verification is not release publication, installation authorization, deployment approval, certification, or risk acceptance. See Sovereign Offline Distribution Bundle.

Restricted security advisory and LTS channel

Security fixes for restricted environments use a separately signed advisory package that binds affected/fixed versions, severity, mitigations, VEX updates, support track, rollback identity, and an opaque offline patch bundle. Generate and pinned-verify the package with fairway security advisory export|verify. The patch artifact must still pass its own release-assurance and offline-bundle verification before customer import.

The support and end-of-support policy is canonical in SECURITY.md; operator steps and trust-root rotation are in Restricted Advisory and LTS Patch Channel. Customer acknowledgement is receipt evidence only. No advisory command publishes, notifies, imports, installs, approves, deploys, accepts risk, or changes dashboard/public exposure.

Docs portal deployment

The public Fairway docs portal should use separate Cloudflare credentials for fairway.run. Do not reuse credentials from any consumer project or domain.

Expected secret split:

SecretPurpose
FAIRWAY_CLOUDFLARE_API_TOKENCloudflare Pages/DNS token scoped only to the Fairway zone/project.
FAIRWAY_CLOUDFLARE_ACCOUNT_IDCloudflare account for Fairway Pages deploys.
FAIRWAY_CLOUDFLARE_ZONE_IDZone id for fairway.run.
FAIRWAY_PAGES_PROJECTCloudflare Pages project name.

Minimum Cloudflare token permissions:

ScopePermissionWhy
Account / selected Fairway accountCloudflare Pages Edit or Pages WriteCreate/update Pages projects and publish deployments.
Account / selected Fairway accountAccount Settings ReadResolve account metadata during Pages deploy/setup tooling.
Zone / fairway.run onlyDNS Edit or DNS WriteCreate/update fairway.run, www.fairway.run, and docs.fairway.run DNS records.
Zone / fairway.run onlyZone ReadResolve the zone and validate custom-domain routing.

If Cloudflare Pages is connected directly to GitHub and Cloudflare owns deploys, the CI token may not need DNS write after initial custom-domain setup. Keep DNS write on a setup/admin token when possible, and use a narrower Pages-only deploy token for routine GitHub Actions deploys.

Local Fairway Cloudflare credentials may be stored in .env.cloudflare.fairway-run. The file is ignored by git and must not be printed, committed, or mixed with consumer deployment credentials.

Docs portal edge security

Do not assume Cloudflare Pages alone is the complete security posture. The docs portal setup should explicitly verify the following before the site is treated as production-ready:

  • Cloudflare bot protection is enabled for fairway.run using the available plan capability, such as Bot Fight Mode, Super Bot Fight Mode, or an equivalent managed bot rule.
  • Security headers are shipped from the Pages project, normally through a _headers file in the static output path:
    • X-Frame-Options: DENY
    • X-Content-Type-Options: nosniff
    • Referrer-Policy: strict-origin-when-cross-origin
    • Permissions-Policy with unused browser features disabled
    • Content-Security-Policy appropriate for the generated Docusaurus assets
  • The Pages preview host is not indexed if preview deployments are public. Use X-Robots-Tag: noindex for *.pages.dev preview hosts where appropriate.
  • Custom-domain routing is verified for fairway.run, www.fairway.run, and docs.fairway.run.
  • Any WAF/bot challenge rule is checked against normal docs traffic, GitHub release downloads, Homebrew install paths, and legitimate search crawlers.

Cloudflare setup belongs to its own tracked task because it includes domain, token, static-site, and public-content boundary decisions.

Shared dashboard hostname updates

Shared read-only dashboard hostnames are deployment-owned. Use a neutral Fairway hostname under the deployment owner's domain, while temporarily keeping an older consumer-named hostname as a compatibility alias when migration evidence requires it.

Changing that hostname does not require a GoReleaser or Homebrew cask change as long as Fairway does not embed the public dashboard URL in the binary, archives, cask metadata, or generated release assets. Treat it as a docs/deployment release note item:

  • verify the new DNS, tunnel, and Access policy in the deployment-owned task;
  • update Fairway docs to prefer the neutral hostname;
  • keep the old hostname until replacement reachability and viewer communication are recorded;
  • mention the neutral reference hostname in release notes only after the deployment owner is ready to advertise it.

Historical consumer hostname decisions remain in the archived dashboard share plan.

Pre-1.0 distribution

  • Homebrew cask for tagged releases: brew tap fairway-run/tap && brew install --cask fairway.
  • Direct downloads from GitHub Releases.
  • Source install before the first tag: go install github.com/fairway-run/fairway/cmd/fairway@latest.
  • Local checkout install: make install (defaults to ~/.local/bin/fairway).

Post-1.0 distribution

  • Homebrew cask remains the primary macOS path.
  • Possibly Scoop, Nix, AUR — community-contributed.

Yanking a release

A release with a critical bug is yanked by:

  1. Deleting the tag on the remote (git push --delete origin vX.Y.Z).
  2. Editing the GitHub Release to "Draft" with a note explaining.
  3. Cutting vX.Y.Z+1 with the fix.

Never re-use a version number.