Module: .github/workflows, .goreleaser.yaml, .svu.yaml, internal/release, internal/prompt, internal/selfupdate, internal/version, internal/cli (release, version) · Milestone: M7+ (release-engineering / beta polish lane — gates the v0.2.0 cut) · Effort: ~1.5w
Releases today are 100% manual: a human picks a tag and pushes it; release.yml then runs goreleaser. There is no commit analysis, no automatic version, no grouped changelog. This spec turns conventional commits on main into an automatic v<MAJOR.MINOR.PATCH> tag → grouped changelog → goreleaser release, wired so the existing tag-triggered release engine and self update/notifier (spec 14) keep working unchanged. The project is BETA: the machinery is pinned to 0.x (a breaking change bumps the minor, never to 1.0.0), and the next release this enables is v0.2.0 — not v1.0.0. It also fixes a latent, now load-bearing bug: the ldflags inject a v-stripped version that x/mod/semver rejects, which would silently mute both the notifier and self update the moment automated comparison becomes the whole point. A maintainer-facing devstack release wizard (huh v2 TUI, fully --json/flag-degradable) previews the computed version + changelog and optionally cuts the tag.
- Version computation =
caarlos0/svu(pure-Go, by the goreleaser author), always--v0. CI runssvu next --v0to derive the next tag from conventional-commit history. Rejected: release-please (Node action, opens a "release PR", owns the tag — reshapes the flow off goreleaser-on-tag), go-semantic-release (wants to own publishing, overlaps goreleaser), git-cliff (Rust, changelog-only, no version decision — goreleaser already does conventional changelogs), node semantic-release (heaviest, full Node toolchain, duplicates goreleaser). svu is the idiomatic goreleaser pairing and a single static binary. - Stay on 0.x via
--v0(KeepV0), enforced twice.svu --v0makes a BREAKING change bump the minor (0.1.x → 0.2.0), never 1.0.0. A CI guard step fails the run if the computed/about-to-push tag hasMAJOR != 0..svu.yamlsetsv0: trueso the flag can never be forgotten. - Single combined workflow, built-in
GITHUB_TOKEN, no PAT/App token (owner decision). Onerelease.ymltriggers onpush: main(automated) andpush: tags: ["v*"](a human hand-cut tag) andworkflow_dispatch. On a main push it runssvu next --v0, applies the 0.x guard, and — gated by the repo variableRELEASE_ENABLED == 'true'— tags and runs goreleaser in the same job. Compute and release run together on purpose: a tag pushed with the defaultGITHUB_TOKENdoes not re-trigger workflows (Actions suppresses events fromGITHUB_TOKEN), so the only way to avoid a separate token is to never depend on a tag-push event re-firing a second workflow — fold them into one run. goreleaser needs onlycontents: write(whichGITHUB_TOKENgrants) to create the Release. Kill-switch = theRELEASE_ENABLEDrepo variable (default unset ⇒ compute + log, never release), honoring the owner-only release-flip (PROGRESS decision #4) without a managed secret. A humangit tag vX.Y.Z && git pushstill releases (the same workflow's tag trigger; human pushes are not suppressed and are ungated — explicit intent). Rejected the two-workflow +RELEASE_TOKENsplit: it keepsrelease.ymlperfectly reusable but costs a no-expirycontents:writesecret to manage — not worth it for a solo/beta project. - Single changelog source of truth = goreleaser, grouped by conventional type. Upgrade
.goreleaser.yamlchangelogtogroups:(Features/Fixes/Performance/…) withfilters.excludeforchore|docs|test|ci|build|style. Grouping/filtering apply withuse: github(anduse: git) but are ignored underuse: github-native, so the config must stay ongithub.@semantic-release/release-notes-generatoris not added — two changelog generators is two sources of truth. goreleaser remains the sole creator of the GitHub Release + notes. - Fix the ldflags v-prefix (load-bearing). Change
.goreleaser.yamlldflags fromversion.Version={{.Version}}toversion.Version=v{{ .Version }}so the stampedinternal/version.Versionis a clean, v-prefixed semver thatx/mod/semveraccepts. The archivename_templatestays on the v-stripped{{ .Version }}to matchassetName'sTrimPrefix(spec 14,update.go:78). A CI step asserts the built binary'sversion --shortoutput issemver.IsValid. - Conventional-commit input is guarded. Squash-merge makes the PR title the commit subject, so commit analysis is only as reliable as PR titles. Add a pure-shell PR-title lint (
pull_requesttypesopened|edited|synchronize) — no Node dep — that fails on a non-type(scope)?:subject. Repo setting: squash-merge only, "PR title" as the squash commit message. devstack release(maintainer command) wraps svu; never the only path. A newinternal/releaseshells to thesvubinary behind an interface (mockable, likeinternal/git/internal/docker);internal/cliaddsreleasewhich previews next version + grouped changelog and optionally creates/pushes the tag. Interactive confirm viacharm.land/huh/v2behindinternal/prompt, with a non-TTY/--yes/--json/--dry-runfallback that never starts the bubbletea runtime.- Extends (not redefines) the spec-14 pre-release rule. Spec 14 already mandates "ignore pre-releases unless the running build is itself a pre-release." This spec adds an additive, forward-tolerant
update.channel: stable|prereleasekey (defaultstable) underapiVersion: devstack/v1so a maintainer can opt into-rc/-betatags. Spec 14 remains the owner of the notifier behavior; this spec only wires the config knob. The plain 0.x.y plan never emits pre-releases, but the knob makes a futurev0.3.0-beta.1safe rather than a surprise to stable users.
# .svu.yaml — v0 is non-negotiable while BETA. Keep this minimal: only `v0` is a
# verified key here. svu's DEFAULT tag format is already v<semver> — do NOT add a
# channel/range/build-meta suffix, which would break release-dryrun for every PR.
v0: true # KeepV0: a BREAKING change bumps the MINOR (0.1.x → 0.2.0), never 1.0.0# .goreleaser.yaml — the v-prefix fix (the rest of builds: unchanged)
ldflags:
- -s -w
- - -X github.com/open-source-cloud/devstack/internal/version.Version={{.Version}}
+ - -X github.com/open-source-cloud/devstack/internal/version.Version=v{{ .Version }}
- -X github.com/open-source-cloud/devstack/internal/version.Commit={{.ShortCommit}}
- -X github.com/open-source-cloud/devstack/internal/version.Date={{.Date}}
archives:
- id: default
formats: [tar.gz]
name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}" # UNCHANGED — stays v-STRIPPED to match assetName's TrimPrefix
changelog:
use: github # MUST stay `github` (or `git`); `github-native` ignores groups/filters
sort: asc
+ groups:
+ - { title: "Features", regexp: '^.*?feat(\(.+\))?!?:.*$', order: 0 }
+ - { title: "Bug fixes", regexp: '^.*?fix(\(.+\))?!?:.*$', order: 1 }
+ - { title: "Performance", regexp: '^.*?perf(\(.+\))?!?:.*$', order: 2 }
+ - { title: "Others", order: 99 }
+ filters:
+ exclude: ['^chore', '^docs', '^test', '^ci', '^build', '^style', '^Merge ']# .github/workflows/release.yml — REPLACES the old tag-only release.yml. One job,
# two entry points, built-in GITHUB_TOKEN only (the old tag.yml is removed).
name: Release
on:
push:
branches: [main]
tags: ["v*"]
paths-ignore: ["**/*.md", "docs/**", "LICENSE", "NOTICE"]
workflow_dispatch: {}
permissions: { contents: write } # tag push + goreleaser Release, both via GITHUB_TOKEN
concurrency: { group: release-${{ github.ref }}, cancel-in-progress: false }
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with: { fetch-depth: 0 } # full history + tags for svu + goreleaser
- uses: actions/setup-go@v5
with: { go-version: "1.25", check-latest: true }
# --- automated path (push to main / dispatch): compute + tag ---
- name: install svu (pinned)
if: ${{ !startsWith(github.ref, 'refs/tags/') }}
run: go install github.com/caarlos0/svu/v3@v3.4.1 # pinned to the real latest v3.x
- name: compute next version
id: svu
if: ${{ !startsWith(github.ref, 'refs/tags/') }}
run: |
CUR="$(git describe --tags --abbrev=0 2>/dev/null || echo v0.0.0)"
NEXT="$(svu next --v0)"
echo "current=$CUR" >> "$GITHUB_OUTPUT"; echo "next=$NEXT" >> "$GITHUB_OUTPUT"
- name: 0.x guard (a stray feat!/BREAKING must NEVER yield v1.0.0 while in BETA)
if: ${{ !startsWith(github.ref, 'refs/tags/') }}
run: |
case "${{ steps.svu.outputs.next }}" in
v0.*) echo "ok" ;;
*) echo "::error::refusing non-0.x tag ${{ steps.svu.outputs.next }} while in BETA"; exit 1 ;;
esac
- name: gate + tag (only when RELEASE_ENABLED + a real bump)
id: gate
if: ${{ !startsWith(github.ref, 'refs/tags/') }}
run: |
if [ "${{ vars.RELEASE_ENABLED }}" != "true" ]; then
echo "go=false" >> "$GITHUB_OUTPUT"; echo "disabled (computed ${{ steps.svu.outputs.next }})"; exit 0; fi
if [ "${{ steps.svu.outputs.next }}" = "${{ steps.svu.outputs.current }}" ]; then
echo "go=false" >> "$GITHUB_OUTPUT"; echo "no release due"; exit 0; fi
git config user.name "devstack-release[bot]"; git config user.email "release@devstack.local"
git tag "${{ steps.svu.outputs.next }}"
git push origin "${{ steps.svu.outputs.next }}" # GITHUB_TOKEN: does NOT re-trigger this workflow → no double release
echo "go=true" >> "$GITHUB_OUTPUT"
# --- release: on a manual tag push, or right after auto-tagging ---
- uses: goreleaser/goreleaser-action@v6
if: ${{ startsWith(github.ref, 'refs/tags/') || steps.gate.outputs.go == 'true' }}
with: { version: "~> v2", args: release --clean }
env: { GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}" }Automated release pipeline (CI), all in one release.yml job:
- PR merges to
main(squash; PR title is the commit, already conventional-lint-checked on the PR). release.yml(onpush: main) checks out full history, installs the pinnedsvu, runssvu next --v0→ e.g.v0.2.0.- 0.x guard: if the computed tag is not
v0.*, fail the job (afeat!/BREAKING CHANGEduring BETA must bump the minor, not major). - Gate: if
vars.RELEASE_ENABLED != 'true', or the computed tag equals the latest tag, log and exit 0 (no release — the kill-switch). Otherwisegit tag+git push origin <tag>withGITHUB_TOKEN(this push does not re-trigger the workflow's own tag filter — GITHUB_TOKEN events are suppressed — so there is no double release). - In the same job, goreleaser
release --cleanruns (it sees the freshly created tag viagit describe): builds the 4 CGO-free targets, stampsversion.Version=v0.2.0, emits the grouped changelog, archives (v-stripped names) +checksums.txt+.deb/.rpm, creates the GitHub Release withGITHUB_TOKEN. - A user on v0.1.0 runs any command → the notifier's
LatestTagresolvesv0.2.0(v-prefixedtag_name),semver.Compare("v0.2.0","v0.1.0")>0→ footer shown.devstack self updatedownloadsdevstack_0.2.0_<os>_<arch>.tar.gz(v-stripped), verifieschecksums.txt, atomically replaces — all unchanged from spec 14, now actually reachable because the stamped version is semver-valid.
A human-cut tag (git tag vX.Y.Z && git push) takes the other branch of the same workflow: the svu/gate steps are skipped (github.ref starts with refs/tags/) and goreleaser runs directly — so a manual release needs no RELEASE_ENABLED and no token (human pushes are not suppressed).
devstack release (maintainer, interactive — local convenience over the same svu):
- Resolve current tag +
svu next --v0; render a grouped changelog preview (commits since the last tag, bucketed by conventional type). - TTY + interactive: a huh v2 form shows
current → next, the changelog, and a confirm (Create and push tag v0.2.0?). On confirm:git tag+ (optional)git push. - Non-TTY /
--yes/--json/ CI: never start the TUI.--jsonprints{current,next,bump,changelog,wouldPush}and exits;--dry-runprints the plan and writes nothing;--yestags non-interactively;--no-pushtags locally only. release --checkprints the next version and exits 0 if a release is due, non-zero if not (scriptable gate, reads asif devstack release --check; then ...). Note the deliberate inversion: unlike the drift-stylegenerate --check/ws --check(non-zero = action needed), here exit 0 = a release is due; both codes are documented so it is never confused with drift detection.--quietsuppresses all but the version.
The one-time v0.2.0 cut (owner): the current sole tag is v0.1.0; the accumulated feat: history since makes svu next --v0 resolve v0.2.0. The owner enables auto-release by setting the repo variable RELEASE_ENABLED=true (gh variable set RELEASE_ENABLED --body true) and triggering the workflow (a feat: merge to main, or workflow_dispatch); or cuts it by hand with git tag v0.2.0 && git push origin v0.2.0. No 0.0.0 seeding hazard exists here — the repo already has a non-zero v0.1.0 baseline.
- The v-stripped ldflags is a silent muter, not cosmetic. goreleaser's
{{ .Version }}is the tag without the leadingv(0.2.0), butx/mod/semverrequires thevand treats malformed input as lowest. Confirmed against the code:internal/selfupdate/selfupdate.goIsDevBuild(v)returns true when!semver.IsValid(v), andnotify.go/update.goshort-circuit onIsDevBuild. A goreleaser binary stamped0.2.0→IsDevBuild=true→ the notifier goes silent andself updatenever sees an update. Fix the ldflags tov{{ .Version }}; keep the archivename_templatev-stripped (it must matchassetName = devstack_<TrimPrefix v>_<os>_<arch>.tar.gz,update.go:78). These two opposite conventions are both load-bearing — do not "unify" them. - A tag pushed with
GITHUB_TOKENdoes NOT trigger any workflow — this is why tag + release live in one job. GitHub deliberately suppresses workflow events from the default token to prevent recursion (exceptions:workflow_dispatch/repository_dispatch).contents: writeis enough to push a tag, but the push won't wake a separate tag-triggeredrelease.yml— those are two different things. The chosen design dodges the problem entirely by runningsvu-compute → tag → goreleaser in the same job, so nothing depends on a re-trigger and the built-inGITHUB_TOKENsuffices (no PAT/App token). The alternative — a separatetag.ymlpushing with a PAT/AppRELEASE_TOKENso the push does firerelease.yml— is the standard "release bot" pattern but costs a managedcontents:writesecret; rejected here for a solo/beta repo. - Kill-switch is a repo variable, not a secret. Gate the automated tag/release steps on
vars.RELEASE_ENABLED == 'true'(default unset ⇒ compute + log, never release). It is avars.*(notsecrets.*) value, readable directly inrun:/if:, with no token to rotate. A human-cut tag (git tag && git push) is ungated by design — explicit intent — and reaches goreleaser via the same workflow'srefs/tags/branch. - Only ever push
v<MAJOR.MINOR.PATCH>tags. A non-semver tag (a channel/range/build-meta suffix, or a milestone label likem2-done) breaks therelease-dryrunjob'sgoreleasergit describefor every PR (ci.yml jobrelease-dryrunrunsrelease --snapshot --clean; PROGRESS decision #1). svu's defaulttagFormatv${version}satisfies this;.svu.yamlmust never add a suffix. --v0is not the default — forget it and BETA breaks. Plainsvu nextbumps a BREAKING change to 1.0.0. Always pass--v0(and setv0: truein.svu.yaml) and keep the CIv0.*guard as defense-in-depth. (Note the release-please trap if you ever switch tools: with a0.0.0manifest itsbump-*-pre-majorflags are ignored and it recommends 1.0.0 — you must seed at0.1.0. svu reading the existingv0.1.0tag avoids this entirely.)x/mod/semverorders 0.x and pre-releases correctly (repo vendors v0.37.0):Compare("v0.2.0","v1.0.0")<0,Compare("v0.2.0-beta.1","v0.2.0")<0, dotted identifiers numeric (beta.2 < beta.10). StayingMAJOR=0keeps every notifier/self-update comparison valid; tag any beta asvX.Y.Z-beta.Nso it sorts before the final.- huh v2 must be the v2/charm.land line, never v1. Add
charm.land/huh/v2(latest v2.0.x, e.g. v2.0.3) + its transitivecharm.land/bubbletea/v2andcharm.land/bubbles/v2, byte-aligned with the already-vendoredcharm.land/lipgloss/v2 v2.0.1+charm.land/fang/v2 v2.0.1.github.com/charmbracelet/huh(v1) pulls bubbletea v1 / lipgloss v1 and double-vendors a conflicting charm stack. The whole family is pure-Go terminal I/O (x/term, x/ansi, ultraviolet — already vendored) → CGO_ENABLED=0 safe, no build tags; re-runmake vulnafter adding. (See residual risks: a stdlib y/n prompt is a viable zero-dep alternative for a confirm this simple.) - huh is a bubbletea program — gate it on a TTY.
form.Run()errors when stdin is not a TTY; wrap it behindinternal/promptwith a non-TTY/--json/--quiet/CIfallback to flags so the headline-output contract (ARCHITECTURE §7.9) holds and a CI invocation never hangs waiting for input. huh's.WithAccessible()is a degraded-but-usable middle path. - Conventional-commit input is only as good as squash-merge hygiene. Under squash-merge the PR title becomes the commit subject; an unlinted title silently corrupts version computation (a
fixtypo'd asbugfix→ no bump). Lint PR titles (pure shell regex, no Node action) and require "PR title" as the squash message in repo settings. devstack releasemutates nothing shared — no flock. It reads git, computes a version, and creates a git tag; it touches neither the SQLite ledger nor the shared stack, so (likedevstack import, spec 14) it takes nointernal/lock. Determinism/golden artifacts are untouched: the ldflags change alters only the stamped version string, never generated compose output. No token or secret value is ever written to a generated file or printed byrelease/version(the no-plaintext rule, spec 04).- Don't add a redundant
.env/changelog parser. Conventional-commit grouping lives in goreleaser config (regex on subjects); no new Go parser is needed. If structured env handling is ever required in CI scripts, reuse the already-vendoredgithub.com/compose-spec/compose-go/v2/dotenv— notjoho/godotenv/hashicorp/go-envparse.
- Merging a
feat:PR tomainwithRELEASE_ENABLED=truemakesrelease.ymlcomputev0.2.0viasvu next --v0, tag it, and run goreleaser in the same job → a GitHub Release with grouped (Features/Fixes/…) notes, using onlyGITHUB_TOKEN. - A
feat!:/BREAKING CHANGEcommit while in BETA computes a minor bump (e.g.v0.2.0), neverv1.0.0; the CIv0.*guard fails the run if any non-0.x tag would be produced. - When
RELEASE_ENABLEDis unset/nottrue,release.ymllogs the computed version and exits 0 without tagging or releasing (kill-switch); when the computed tag equals the latest existing tag it also no-ops. A human-cutgit tag vX.Y.Z && git pushreleases via the same workflow'srefs/tags/path with no variable and no token. - A goreleaser-built binary reports
version --short= a v-prefixed string thatsemver.IsValidaccepts (verified on both a real tag and the--snapshotbuild, whosev0.1.1-dev-<sha>is still valid semver); a CI step asserts it. The release archive filename stays v-stripped (devstack_0.2.0_<os>_<arch>.tar.gz). - After v0.2.0 is published, a v0.1.0 user sees the notifier footer and
self updateinstalls v0.2.0 (checksum-verified) with no other change to spec 14 code — i.e. the previously-muted path now fires. - A PR whose title is not
type(scope)?: subjectfails the PR-title lint; a conventional title passes. No Node toolchain is added to CI. -
devstack release --jsonprints{current,next,bump,changelog,wouldPush}and writes nothing;--dry-runprints the plan;--yestags non-interactively; on a non-TTY /CI,releasenever starts the bubbletea runtime. -
devstack release --checkexits 0 when a release is due and non-zero otherwise (gate semantics, documented as inverted fromgenerate --check). -
release-dryrun(goreleaser release --snapshot --clean),make determinism, and the golden tests stay green after the ldflags/changelog/.svu.yamlchanges; onlyv*tags exist in the repo. - The notifier suppresses
-rc/-betatags unlessupdate.channel: prerelease(or the running build is itself a pre-release); av0.3.0-beta.1tag does not nag a stable user. Theupdate.channelkey loads forward-tolerantly underapiVersion: devstack/v1(spec 01/spec 14). - Adding
charm.land/huh/v2+ transitive bubbletea/bubbles v2 keepsCGO_ENABLED=0 go build ./..., the 4-target cross-compile, andmake vulngreen; no charm v1 packages entergo.mod.
Consumes internal/version (the corrected ldflags target it stamps and every comparison reads; the existing version command in root.go gains --short/--json), internal/git (spec 06, reading commit history + creating/pushing the tag for devstack release), internal/config (the additive update.channel key under apiVersion: devstack/v1, forward-tolerant per spec 14/spec 01). New internal/release (svu wrapper + mock) and internal/prompt (huh v2 wrapper + non-TTY fallback) are consumed by internal/cli (release, and the version --short/--json flags). internal/selfupdate (spec 14) is the direct beneficiary — the v-prefix fix and the update.channel knob make its notifier + self update actually reachable. CI consumers: release.yml (rewritten — one job: push: main computes+tags, push: tags releases, built-in GITHUB_TOKEN), pr-title.yml (new PR-title lint), ci.yml (the existing release-dryrun job stays). External tools (CI-only, not in go.mod): pinned github.com/caarlos0/svu/v3 and the existing goreleaser. New module deps (full slice only): charm.land/huh/v2 (latest v2.0.x) + charm.land/bubbletea/v2 / charm.land/bubbles/v2 (v2 line). Thin vs full: the thin slice (.svu.yaml + the release.yml rewrite + ldflags/changelog fix + 0.x guard + RELEASE_ENABLED gate + PR-title lint) is ~0.75w and is all that's needed to cut v0.2.0; the devstack release wizard + internal/release/internal/prompt + update.channel filter add ~0.75w.
- Orchestration shape — two-workflow (App-token) vs single-job. Two workflows keep
release.ymlreusable and the kill-switch a token-absence, at the cost of one managed App/PAT secret. A single job (svu-compute + tag + goreleaser together) needs no extra token but folds tagging into the release run. Recommendation: two-workflow +RELEASE_TOKEN. Decision (owner, revised): single combined workflow + theRELEASE_ENABLEDrepo variable, built-inGITHUB_TOKEN, no PAT — the owner opted to avoid managing a secret; the one job handles both the automated (push: main, gated) and manual (push: tags) paths. Revisit only if a standalone reusable tag→release engine becomes necessary. - Confirm UI — huh v2 vs stdlib y/n. huh aligns with the existing fang/lipgloss v2 stack but vendors a full bubbletea/v2 runtime for one confirm. A
bufio.Scannery/n prompt behind the sameinternal/promptinterface adds zero module deps. Recommendation: ship the stdlib prompt for the thin slice; adopt huh only if/when the wizard grows multi-field input. Decision: keepinternal/promptas the seam either way; default to stdlib, leave huh as a drop-in upgrade. - Committed
CHANGELOG.mdvs release-notes-only. goreleaser grouped notes live on the GitHub Release; a committedCHANGELOG.mdwould need git-cliff/semantic-release (a second generator) and a bot commit tomain. Recommendation: release-notes-only — single source of truth, no second tool, no determinism/bot-commit churn. Decision: release-notes-only for v0.2.0; reconsider a generatedCHANGELOG.mdartifact (not committed) post-1.0. - PR-title lint: shell regex vs
amannn/action-semantic-pull-request. Recommendation: pure-shell regex (no Node action, consistent with the "no Node toolchain" stance). Decision: shell regex now; swap to the marketplace action only if richer scopes/config are needed. devstack releasesource of truth: shell-to-svuvs reimplement bump in pure Go. Shelling matches the repo's "wrap external tool behind aninternal/interface" rule and guarantees lockstep with CI; reimplementing avoids a runtime dep but risks divergence. Recommendation: shell tosvubehindinternal/releasewith a "svu not found" remediation (maintainer command). Decision: wrapsvu; a CI test assertsdevstack release --checkagrees withsvu next --v0.- Pre-release channels in 0.x. The plain plan never emits
-beta/-rc, but spec 14 mandates the ignore-pre-releases rule. Recommendation: implement theupdate.channelknob now (cheap) so a future beta is safe. Decision: implement additively; defaultupdate.channel: stable, spec 14 owns the behavior.