Releasing Portwing
July 11, 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 and
checksums.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 → test (-race) → govulncheck → fuzz smoke → 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.ymlhas a successful run on HEAD - Computes the next semver from emoji-conventional-commit history (
✨ feat= minor, anything else = patch,!in the commit subject = major; aBREAKING CHANGEfooter alone does not trigger a major bump today) - 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.
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, and checksums; builds and pushes the multi-arch container image to
ghcr.io/codeswhat/portwing; cosign keyless-signs the images (docker_signs); attaches everything to the GitHub release - Attestations — SLSA build provenance for every archive in
checksums.txtand for the container manifest (gh attestation verify <archive> --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 the release:
- GitHub Actions: the
release.ymlrun is green - GHCR image exists:
docker pull ghcr.io/codeswhat/portwing:<version> - The release page has archives for every platform plus
checksums.txt portwing 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
." )