Releases go out through Changie (changelog) and GoReleaser (build + publish). Release notes are authored as Changie fragments during development and batched at release time — commits and PR labels are not used to generate them.
Versions follow Semantic Versioning: vMAJOR.MINOR.PATCH.
| Pipeline | Trigger | Produces |
|---|---|---|
Release (release.yml) |
pushing a v* tag, or manual dispatch |
GitHub Release with binaries and checksums, versioned *-alpine images, a Homebrew formula |
CD (cd.yml) |
every successful CI run on a push | rolling multi-arch latest / branch images to GHCR |
CD keeps ghcr.io/skyoo2003/devcloud:latest current with main. This document
covers the Release pipeline.
Any user-facing change should carry one:
changie newYou are prompted for a kind (Added, Changed, Deprecated, Removed,
Fixed, Security, Documentation), a body, and the issue number. It
writes a small YAML file under changes/unreleased/ — commit it alongside your
code. Prefer changie new over hand-writing the YAML: it enforces the issue
number — a fragment without one renders as a dead link — and refuses a body over
200 characters.
The changelog is a scan surface, not a design document — the reader is deciding whether this release affects them. Root cause, mechanism, measurements and rejected alternatives belong in the linked issue and PR, which every entry already points at.
- One sentence — what changed, and what it means for someone using DevCloud.
- A second only if a reader must not miss it — a limit, a behaviour change they have to act on, or the one figure that makes the entry meaningful.
- Never a third. If it needs one, it is either two changes (write two fragments) or a story that belongs in the issue.
changie new refuses a body over 200 characters (body.maxLength in
.changie.yaml). That is roughly one sentence: at 200 the
second sentence is already crowding the first, so the ceiling and the rule push
the same way. It does not see a fragment you hand-write.
# too long — 417 characters; the root cause, the mechanism and the evidence all
# belong in #142
body: 'S3 Control requests were served by S3. `s3control` signs with S3''s own
signing name, so every call fell through to the REST-XML default and the S3
provider parsed it as a bucket and key — `CreateAccessPoint` returned 200 and
left an object in a bucket named `v20180820`. S3 Control is now split off by its
`/v20180820/` path prefix, and its unserved operations return a clean AWS error
instead of a fabricated success'
# right length — 193 characters
body: 'S3 Control requests were served by S3, which answered `CreateAccessPoint`
with a fabricated 200. It is now split off by its `/v20180820/` path prefix and
its unserved operations decline cleanly'Config: .changie.yaml.
The first three are re-run by the Release workflow against the tagged commit, which refuses to publish if any fails — tick them to find out on your machine rather than from a failed tag. The rest are only caught here.
- Generated code is current —
rm -rf internal/generated && make codegen && git status --porcelain internal/generatedprints nothing. Clear the tree first, as CI does: the generator overwrites the outputs it still emits but never removes one it has stopped emitting, so regenerating in place leaves a retired file looking current. A stale fidelity manifest misreports what the release can be trusted to do. - boto3 compatibility passes —
make test-compat. - Go tests pass —
CGO_ENABLED=0 go test ./.... -
mainis green, including lint and CodeQL. - Every unreleased fragment carries an issue number —
grep -L 'Issue: "[0-9]' changes/unreleased/*.yamlprints nothing. -
changes/unreleased/is not empty. No fragments means either nothing shipped or someone forgot one. - Every fragment body is one sentence, two at most. A third sentence is either a second change that needs its own fragment or detail that belongs in the issue.
- Deprecation review. If this release removes anything previously deprecated — a config key, an env var, an admin route — confirm it shipped for at least one release with a warning first. Removing without that overlap is a major-version change. Full procedure: docs/compatibility-policy.md.
- Compatibility review. Changing anything on the guaranteed list in docs/compatibility-policy.md is a major bump — or it is a bug. Additive change (a new config key, response field, or service) is a minor bump.
-
Confirm
mainis green and holds everything you want in the release. -
Batch the fragments, picking the next version per SemVer:
make changelog VERSION=v1.1.0
That is
changie batch v1.1.0 && changie merge: it consumeschanges/unreleased/, writeschanges/v1.1.0.md, and regeneratesCHANGELOG.md. Run the two commands separately if you want to inspect the batched file first. -
Commit the generated files:
git add changes/ CHANGELOG.md git commit -m "chore(release): v1.1.0" git push origin main -
Tag and push. The tag must match the batched version — the workflow fails if
changes/<tag>.mddoes not exist.git tag v1.1.0 git push origin v1.1.0
- Pins the commit once. The tag is resolved to a SHA up front and that same SHA is checked out in every following job, so moving the tag mid-run cannot make the guardrails vouch for one commit while GoReleaser publishes another.
- Re-runs the guardrails against that commit and stops before publishing
anything if any fails: the Go suite on amd64 and arm64, the boto3 suite (against
a GoReleaser-built binary, not
go build), and the codegen drift check. CI is not relied on — it races the tag, andcompat.ymldoes not trigger on tags at all. - Validates the notes.
changes/v1.1.0.mdmust exist, carry exactly one version heading naming this tag (a file copied from an earlier release is rejected), contain only whatchangie batchrenders, and end every entry in a valid issue link. - Builds and publishes — binaries for darwin/linux/windows × amd64/arm64 as
tar.gz(zipon Windows) with a SHA-256CHECKSUMSfile; container images taggedv1.1.0-alpine,v1.1-alpine,v1-alpine,latest-alpine; the Homebrew formula; and a GitHub Release whose notes come fromchanges/v1.1.0.md.
Config: .goreleaser.yaml.
Archives carry the docs/ tree and the top-level files it and README.md link
to, so docs are versioned by tag — the docs/ inside
devcloud_v1.1.0_linux_amd64.tar.gz describes exactly the binary beside it. There
is no separate docs site to version.
GoReleaser's brews section publishes Formula/devcloud.rb to a separate tap
repository — homebrew-tap under the same owner, named by HOMEBREW_TAP_OWNER /
HOMEBREW_TAP_REPO in release.yml.
The job's own GITHUB_TOKEN cannot write to another repository, so the workflow
mints a short-lived token from a GitHub App installed only on the tap repo,
using the TAP_APP_ID and TAP_APP_PRIVATE_KEY secrets. If a release fails at
the formula step, check that the App is still installed and neither secret has
expired. Token minting is skipped on a dry run.
The formula's test block only asserts the -h usage text: devcloud is a
long-running server with no subcommands, so actually starting it would hang.
Run the workflow manually from the Actions tab (Release → Run workflow) with a
tag and dry_run: true (the default for manual dispatch). GoReleaser runs in
--snapshot mode: artifacts are built and uploaded to the run, but nothing is
published to GHCR, the tap, or GitHub Releases.
- The tag (
v1.1.0) and the fragment file (changes/v1.1.0.md) must match exactly. changie batch+changie mergemust be committed before the tag is pushed.- The tagged commit must pass the Go suite; the workflow will not publish otherwise.
- Every entry in the batched notes needs an issue number.
- No manual GitHub Release editing — the notes are owned by Changie fragments.