Releasing Portwing
August 18, 2026 · View on GitHub
Before tagging
-
Clean tree on
maingit status # must be clean git log --oneline -3 # confirm HEAD is what you intend to tag -
Go checks
gofmt -l . | grep -v '^.claude/' || true # must print nothing go vet ./... go test -race ./... golangci-lint run -
Vulnerability scan — zero reachable vulnerabilities required before tagging
# Install once: go install golang.org/x/vuln/cmd/govulncheck@latest govulncheck ./... -
Release pipeline dry-run
goreleaser check goreleaser release --snapshot --clean --skip=sign,docker,publish,sbomThe snapshot must produce all platform archives, Linux
deb/rpmpackages, the generated Homebrew cask, andchecksums.txtunderdist/. -
Update CHANGELOG.md
- Rename
## [Unreleased]→## [v<version>] - <YYYY-MM-DD> - Add a fresh empty
## [Unreleased]block above it release-cut.ymlvalidates that a non-empty CHANGELOG entry exists for the computed tag before pushing it; the cut fails if this step is skipped
- Rename
-
No source version bump needed — the binary's version is injected at build time via GoReleaser ldflags (
-X github.com/codeswhat/portwing/internal/protocol.AgentVersion={{.Version}}).AgentVersionininternal/protocol/version.gomust stay avar:-Xsilently does nothing to aconst. -
Lefthook pre-push — runs automatically on
git push. Sequence: clean-tree → goreleaser snapshot → lint → Qlty → test (-race) → govulncheck → fuzz smoke → actionlint → zizmor. The push is blocked if any step fails.
Cutting the tag
Preferred path: use the release-cut workflow.
Go to Actions → 🏷️ Release: Cut → Run workflow on main. The workflow:
- Polls until
ci-verify.ymlhas a successful run on HEAD - Computes the next semver from Conventional Commit history (
feat= minor, anything else = patch,!in the commit subject = major; aBREAKING CHANGEfooter alone does not trigger a major bump today). Tolerates a legacy leading emoji from pre-migration history, so old commits still compute correctly. - Validates the CHANGELOG entry is non-empty for the computed tag
- Creates and pushes an annotated tag using the repo bot identity
This requires the RELEASE_PAT secret (fine-grained PAT, Contents:
read/write on this repo). Tags pushed with the default GITHUB_TOKEN do not
trigger downstream workflows, so without the PAT the tag would never fire
release.yml.
The pushed tag is a plain annotated tag, not GPG/SSH-signed. That's a
deliberate choice, not a gap: GitHub's required_signatures rule can't verify
a tag object minted by Actions without bolting on real key management, and the
Cosign artifact chain below — identity-pinned to release.yml@refs/tags/<tag>
and verified in-workflow — is already the signature of record for everything
the tag points at. A tag-protection ruleset on refs/tags/v* (deletion,
update, and non-fast-forward blocked, no bypass actors, deliberately no
required_signatures) enforces this at the platform level; see
CodesWhat/drydock#759 for the house rationale this repo follows. That ruleset
is a repo-settings change, not a workflow change, so it lands separately from
this file.
The release job also requires HOMEBREW_TAP_TOKEN, a fine-grained token
with Contents read/write access to CodesWhat/homebrew-tap. The default
GITHUB_TOKEN cannot publish to a different repository. Prerelease tags render
the cask for validation but do not upload it (skip_upload: auto).
Manual path (if you need to override the computed version):
git tag -a v<version> -m "release: v<version>"
git push origin v<version>
release.yml fires on any v* tag push.
After tagging
release.yml runs on the tag push:
- GoReleaser — builds all platform binaries, archives, native Linux packages, and checksums; keyless-signs each
deb/rpmand the checksum manifest; publishes the stable Homebrew cask; builds and pushes the multi-arch container image toghcr.io/codeswhat/portwing; cosign keyless-signs the images (docker_signs); attaches everything to the GitHub release - Attestations — SLSA build provenance for every checksummed release asset (archives, native packages, and per-archive SBOMs) and for the container manifest (
gh attestation verify <asset> --repo CodesWhat/portwing) - verify-published — pulls the published image and runs the exact
cosign verify/gh attestation verifycommands an operator would run. Skipped while the repo is private (Sigstore public-ledger verification requires a public repo); it activates automatically when the repo goes public. - verify-native-packages — verifies every package's Sigstore bundle, installs the
amd64deb and rpm in digest-pinned clean distribution containers, checks the systemd unit, and runsportwing version. - verify-homebrew — on stable tags, installs the published cask on macOS, runs
portwing version, and uninstalls it.
Verify the release:
- GitHub Actions: the
release.ymlrun is green - GHCR image exists:
docker pull ghcr.io/codeswhat/portwing:<version> - The release page has archives and native packages for every supported platform, each package has a
.bundle, andchecksums.txtlists them brew install --cask codeswhat/tap/portwinginstalls the tagged stable releaseportwing version(orGET /api/v1/version) on the new image reports the tagged version, not0.1.0— this catches ldflags injection regressions
If something goes wrong
Do not delete the bad release or the tag — that breaks go install version pinning and any existing image digests. Instead:
- Revert the offending commit on
main:git revert <sha> - Tag a patch release following the normal process
- Edit the bad release on GitHub: prepend a warning to the release notes and link to the patched version (e.g. "⚠️ This release contains a known issue — upgrade to v
." )