From 8b4b419ee285dabe534cb47d74c38cbfc7cb5337 Mon Sep 17 00:00:00 2001 From: Gustavo Bertoi Date: Tue, 30 Jun 2026 14:49:41 -0300 Subject: [PATCH 1/3] docs(specs): add specs 22-25 + FEATURES/ROADMAP M8 beta-DX lane Design the four newly requested features as spec-quality docs: - 22 interactive `init` wizard (workspace + shared services) - 23 interactive template & Dockerfile authoring (TUI) - 24 `.env` ingestion -> secrets/vars (no more committed .env) - 25 release automation + 0.x conventional-commit versioning All TUIs are Bubble Tea v2 + the Charm plugin stack (bubbles/lipgloss/huh v2, CGO-free) behind one shared internal/prompt theme, each with a --json/flag fallback. Wire them into FEATURES (#14-17) and a new ROADMAP M8 beta-DX lane that stays on the 0.x line (next release v0.2.0, never an automated 1.0.0). Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/FEATURES.md | 20 +++ docs/ROADMAP.md | 25 ++++ docs/specs/22-init-wizard.md | 117 ++++++++++++++++ docs/specs/23-template-authoring.md | 200 ++++++++++++++++++++++++++++ docs/specs/24-env-ingestion.md | 155 +++++++++++++++++++++ docs/specs/25-release-automation.md | 148 ++++++++++++++++++++ 6 files changed, 665 insertions(+) create mode 100644 docs/specs/22-init-wizard.md create mode 100644 docs/specs/23-template-authoring.md create mode 100644 docs/specs/24-env-ingestion.md create mode 100644 docs/specs/25-release-automation.md diff --git a/docs/FEATURES.md b/docs/FEATURES.md index 5bafe6d..12ea922 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -61,6 +61,22 @@ Strictly opt-in (explicit first-run prompt, default OFF, one-flag disable, docum **13. Remote / cloud shared-services backend · 8w.** ([spec 21](specs/21-remote-shared-backend.md)) Generalize the shared stack to run on a remote Docker host (DOCKER_HOST/SSH context) or a team "shared dev cluster" — one warm, seeded Postgres for a whole team, zero local DB containers. The ref-counting, per-project provisioning, and DNS/alias model are backend-agnostic; this swaps *where* containers run. Pairs with the tunnel work. *The ambitious devbox/Codespaces-class frontier — correctly deferred until the local model is rock-solid.* +### Beta DX lane (post-M7, ships in the v0.2.x 0.x line) + +Surfaced after the M0–M7 build landed (see [PROGRESS](../PROGRESS.md)). These add the **interactive layer the tool has lacked** — there is no TUI in the codebase today — plus the release-engineering glue. They are strictly additive over the shipped substrate and all ship in the **0.x beta line**; none cuts v1.0. Every TUI is built on **Bubble Tea v2 + the Charm plugin stack** (`bubbles`/`lipgloss/v2`/`huh/v2`, all CGO-free) behind one shared `internal/prompt` theme, and every interactive flow keeps a `--json`/flag-driven equivalent (the headline-output contract). **Recommended build order: #17 first** (the release thin slice — it is the gate every other item ships through, and it fixes a live self-update bug), then #14 → #15 → #16. + +**14. Interactive `init` wizard · 2w — the file-authoring front door.** ([spec 22](specs/22-init-wizard.md)) +Guided `devstack init`: a Bubble Tea v2 TUI (left engine-picker + right live `workspace.yaml` preview) picks the shared engines (filtered by template `Provides`), fills typed params from `ParamSpec`, and emits a structurally-validated `workspace.yaml` via one shared goccy ordered emitter (`scaffold.EmitWorkspaceYAML`, which `import` is refactored onto). A fully equivalent flag/`--json`/`--no-input` path runs off-TTY. Pure YAML authorship — no ledger, no Docker, no flock. Introduces the reusable `internal/prompt` substrate the rest of this lane consumes. *Onboarding stops assuming a hand-written config.* + +**15. Interactive template & Dockerfile authoring (TUI) · 2.5w.** ([spec 23](specs/23-template-authoring.md)) +`template new`: a Bubble Tea v2 wizard with a live preview pane (the real `template.Resolve` → `generate.LintResolved` path) that scaffolds `template.yaml` + an optional `build/` tree (Dockerfile) + a golden fixture into the `$DEVSTACK_HOME/templates` store. A thin front-end over the M1 engine; app-vs-engine branch; byte-stable via `writeIfChanged`; lock/ledger/secret-free. Adds the delimiter-collision / param-type / meta-templating lints (shared with `template lint`, so the two can't drift) and closes a real spec-19 gap. *Turns "the way we run services" into something a platform team authors, not hand-writes.* + +**16. `.env` ingestion → secrets/vars · 2.5w.** ([spec 24](specs/24-env-ingestion.md)) +`devstack secrets ingest [<.env>]`: converts a committed dotenv into SOPS+age `secret://` refs + **inlined** config-var literals (not `${env.KEY}` — that resolves empty once the `.env` is deleted; `--from-host` opts a key back to ambient host/CI sourcing) and rewrites the target `devstack.yaml`. `--to sops|aws-sm|infisical`; scaffolds a default sops provider when none is declared. Adds the secrets **Pusher** write capability (the existing providers were Resolve-only); parses via the already-vendored `compose-go/v2/dotenv`; idempotency is decrypt-and-compare. No plaintext on disk (CI leak-test). *The migration on-ramp off committed `.env` files.* + +**17. Release automation + 0.x conventional-commit versioning · 0.75w thin (+0.75w wizard) — the v0.2.0 gate.** ([spec 25](specs/25-release-automation.md)) +Conventional commits on `main` → `svu next --v0` → a `v*` semver tag (owner-gated `RELEASE_TOKEN`; its absence is the kill-switch) → the existing tag-triggered goreleaser. Fixes the load-bearing **ldflags v-prefix bug** (`{{.Version}}` stamps `0.1.0`, which `x/mod/semver` rejects) that currently makes the shipped spec-14 update-notifier + `self update` treat a released binary as a dev build and never offer updates. Adds a grouped goreleaser changelog, a PR-title conventional-commit lint, and a CI `v0.*` guard (stay 0.x: BREAKING → minor, never 1.0.0). Optional `devstack release` maintainer wizard. *Everything else in this lane ships through this pipeline — build it first.* + --- ## At a glance @@ -80,3 +96,7 @@ Generalize the shared stack to run on a remote Docker host (DOCKER_HOST/SSH cont | 11 | Versioned template registry | 2.5w | v2 · [spec 19](specs/19-template-registry.md) | | 12 | Opt-in telemetry | 1.5w | later · [spec 20](specs/20-telemetry.md) | | 13 | Remote/cloud shared backend | 8w | later · [spec 21](specs/21-remote-shared-backend.md) | +| 14 | Interactive `init` wizard (TUI) | 2w | v0.2 beta DX (M8) · [spec 22](specs/22-init-wizard.md) | +| 15 | Template & Dockerfile authoring (TUI) | 2.5w | v0.2 beta DX (M8) · [spec 23](specs/23-template-authoring.md) | +| 16 | `.env` ingestion → secrets/vars | 2.5w | v0.2 beta DX (M8) · [spec 24](specs/24-env-ingestion.md) | +| 17 | Release automation + 0.x versioning | 0.75w+0.75w | v0.2 gate (M8) · [spec 25](specs/25-release-automation.md) | diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 9a8add9..f1a94c7 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -68,6 +68,30 @@ Effort is **person-weeks at production OSS quality** (tests + docs + cross-platf - macOS arm64 CI runner for trust/resolver/Desktop-VM behavior; cache GC; document Docker Desktop licensing + Podman/rootless out-of-scope. - Quickstart + migration guide, secrets threat model, troubleshooting; goreleaser tap + `.deb`/`.rpm`; tag 1.0. +### M8 — Beta DX & release-engineering lane (post-M7, v0.2.x 0.x line) · **~8w** +> Core M0–M7 has shipped (see [PROGRESS](../PROGRESS.md)). This lane is **strictly additive** over the existing substrate and **stays on the 0.x beta line** — it does NOT cut v1.0. The "tag 1.0" in M7 above is **superseded by the beta decision**: the project ships in beta (next release **v0.2.0**); 1.0 is a deliberate, later owner call, never reached by automation. "M8" is a *sequencing* label, not a version commitment. +> Specs: [25](specs/25-release-automation.md) (release automation) · [22](specs/22-init-wizard.md) (init wizard) · [23](specs/23-template-authoring.md) (template authoring) · [24](specs/24-env-ingestion.md) (.env ingestion). All TUIs are Bubble Tea v2 + Charm (`bubbles`/`lipgloss/v2`/`huh/v2`), CGO-free, behind one shared `internal/prompt` theme with a non-TTY/`--json` fallback. + +**M8.0 — Release automation + 0.x versioning (the v0.2.0 gate) · 0.75w thin (+0.75w wizard).** ([spec 25](specs/25-release-automation.md)) +- `.svu.yaml` (`v0: true`) + a `tag.yml` workflow: push-to-`main` → `svu next --v0` → push a `v*` tag (owner-gated `RELEASE_TOKEN` at **job** level so the kill-switch `if:` can read it) → the existing `release.yml`/goreleaser on the tag. +- Fix the ldflags v-prefix bug (un-mutes the shipped spec-14 self-update/notifier); add a grouped goreleaser changelog (`use: github` + `groups`/`filters`); PR-title conventional-commit lint; CI `v0.*` guard. +- Optional: the `devstack release` maintainer wizard + an additive `update.channel` (stable|prerelease) knob (extends spec 14, does not redefine it). +- **Sequence first** — every later item ships through it; the thin slice (~0.75w) is all that is needed to cut v0.2.0. + +**M8.1 — `internal/prompt` + interactive `init` · 2w.** ([spec 22](specs/22-init-wizard.md)) +- Introduce the Bubble Tea v2 + Charm stack behind `internal/prompt` (shared theme + non-TTY/`--json`/`--no-input` fallback so the headline-output contract holds and CI never drives Bubble Tea). +- `devstack init` authors a structurally-valid `workspace.yaml` via a shared `scaffold.EmitWorkspaceYAML` ordered emitter; **refactor `internal/migrate` onto the same emitter** (re-baseline its golden output intentionally). No flock, no Docker. + +**M8.2 — `template new` authoring TUI · 2.5w.** ([spec 23](specs/23-template-authoring.md)) +- One `scaffold.Build(Spec)` pure builder fed by both the wizard and the flag/`--from`/`--json` path; live `template.Resolve` → `generate.LintResolved` preview; app-vs-engine branch; `writeIfChanged` byte-stability. +- Implement the delimiter-collision / param-type / meta-templating lints **once** in shared lint code consumed by both `template new` preview and `template lint`. Reconcile spec-19's overstated `template init` description. + +**M8.3 — `.env` ingestion + secrets Pusher · 2.5w.** ([spec 24](specs/24-env-ingestion.md)) +- `secrets ingest` over the shipped SOPS+age/AWS/Infisical providers; net-new **Pusher** write capability (aws-sm/ssm/infisical) reusing existing auth plumbing; `compose-go/v2/dotenv` parse; decrypt-and-compare idempotency; default-sops-provider scaffold into `workspace.yaml`. No flock. +- Add a `doctor` probe for the `sops` binary min-version (stdin / `--input-type` support). + +**Sequencing within M8:** M8.0 → M8.1 (lands `internal/prompt` + the shared emitter) → M8.2 (reuses both) → M8.3 (reuses prompt, adds the heaviest net-new backend). After each charm-dep add: re-run `make vuln` + the `CGO_ENABLED=0` static cross-build; no build tags may creep in. + --- ## Totals @@ -80,6 +104,7 @@ Effort is **person-weeks at production OSS quality** (tests + docs + cross-platf | Onboarding/glue (M6) | 8 | +~2.5 months | | Hardening/GA (M7) | 6 | +~2 months | | **Full v1 (all pillars)** | **54** | **~13–15 months** | +| Beta DX lane (M8, 0.x — post-GA) | ~8 | +~2.5 months | Calendar applies a 0.6–0.75 throughput factor (context-switching, Docker/WSL2/macOS debugging, dependency churn, docs, CI). Treat as planning ranges, not commitments. diff --git a/docs/specs/22-init-wizard.md b/docs/specs/22-init-wizard.md new file mode 100644 index 0000000..f2600e9 --- /dev/null +++ b/docs/specs/22-init-wizard.md @@ -0,0 +1,117 @@ +# Spec 22 — Interactive `init` wizard (workspace + shared services) + +**Module:** `internal/cli` (`init`), `internal/scaffold` (the ordered emitter + wizard model), `internal/prompt` (the TUI interface + non-TTY fallback) · **Milestone:** post-GA / v0.2 polish lane (after M7, ships in v0.2.0 — the project is in BETA; this never forces a v1.0.0) · **Effort:** ~2w + +## Purpose +Turn the cold-start of a workspace from "hand-author two YAML files against [spec 01](01-config-schema.md) from memory" into a guided, validated `devstack init` that emits a correct-by-construction `workspace.yaml` (and, optionally, per-repo `devstack.yaml` stubs). Today both files are hand-written; there is no `init` command (not even a stub), and the only existing config emitter is `devstack import` ([spec 14](14-self-update-and-migration.md), `internal/migrate`). This spec adds the missing onboarding front door: pick the shared engines (Postgres/Redis/MinIO/…), fill their typed params from template metadata, name the workspace, and write a file that is **structurally validated before it is written** — so the very next `devstack generate`/`up` starts from a parseable, well-formed config. The wizard is a thin TUI over the same data the rest of the tool already exposes (`template.Describe`, `builtinSource()`, `config.Workspace`); its hard requirement is a **fully scriptable, non-interactive equivalent** so CI, `--json`, and non-TTY shells never depend on the TUI. + +## Decisions +- **One command, two faces.** `devstack init` runs a **Bubble Tea v2 TUI** on an interactive TTY and a **flag-/stdin-driven non-interactive path** everywhere else. The two paths build the *same* in-memory `config.Workspace` and call the *same* emitter — the TUI only collects inputs. +- **Authors `workspace.yaml` only (the shared layer).** Name, `aliases`, `profiles.default`, `shared`, and `projects[]` refs. Per-repo `devstack.yaml` stays a separate portable file ([spec 01](01-config-schema.md)); the wizard may scaffold a *minimal* `devstack.yaml` stub per declared project behind an opt-in, but never folds project service config into `workspace.yaml`. +- **The shared-engine catalogue is `builtinSource()` filtered by `Provides`.** Only templates with a non-empty `Provides` (today `postgres`/`redis`/`minio`; plus any store-custom engine that declares one) are offered as shared services. `php.*`/`node.*` (no `Provides`) are project templates and are hidden from the shared picker. Same source `generate` uses, so custom store templates appear automatically ([spec 02](02-templating-and-generation.md)). +- **Per-service param forms are generated from `ParamSpec`.** For each chosen engine, render one field per `template.Describe(...).Params[name]` (`Type` string/int/bool, `Default`, `Required`, `Description`). Required-without-default params are mandatory in the form; defaults are pre-filled and omitted from output when left at default (diff-stable, minimal YAML). +- **Pre-seed from the global store.** If `$DEVSTACK_HOME/config.yaml` exists, offer its `shared` set (`store.Load`, default `postgres@16`/`redis`/`minio`) as the default multi-select selection. The wizard never *writes* the store — it only reads it as a starting point (store authorship stays `store init`'s job). +- **Emit via a shared ordered emitter, not struct marshal.** New `scaffold.EmitWorkspaceYAML(config.Workspace) ([]byte, error)` builds `yaml.MapSlice`/`MapItem` in fixed key order (`apiVersion`, `kind`, `name`, `aliases`, `profiles`, `shared`, `projects`; empty sections omitted) and `yaml.Marshal`s it — the proven `internal/migrate` pattern (migrate.go already hand-builds the same `MapSlice`). `internal/migrate` is refactored to call this same emitter so there is **one** goccy builder, not two divergent ones, with byte-stability golden-tested. +- **Validate before write (structural).** The assembled model is rendered, then the bytes are run through `config.LoadWorkspaceOnly` (or a new exported `config.ValidateWorkspaceBytes`) *before the real file lands* — apiVersion/kind header, every `dsname`, and overall shape. A malformed selection is caught in the wizard / before the file is written, never as a parse failure on the next `generate`. **Note the limit:** this is *structural* validation only; the full shared-graph + `${ref}` cross-resolution that `validateModel`/`generate` perform needs each project's `devstack.yaml` on disk (which init does not require), so that layer is necessarily deferred to the next `generate` once the repos are present. +- **No-clobber + dry-run, mirroring `import`.** Refuse to overwrite the **target dir's** `workspace.yaml` (or any target stub) without `--force`; with `--force`, back up originals first. Separately, if `config.Discover` finds a workspace.yaml in a *parent* dir, refuse with an "already inside a workspace at " error (nested workspaces are unsupported, [spec 01](01-config-schema.md)). `--dry-run` prints the would-be file(s) and a validation verdict, writes nothing. `--out ` redirects output. +- **The TUI is a custom Bubble Tea v2 program — modern aesthetics are a first-class requirement, not a nicety** (owner directive: "a really nice modern TUI for CLIs"). The wizard is a hand-written `charm.land/bubbletea/v2` model composed from `charm.land/bubbles/v2` components — `list` (the shared-engine multi-select, each row showing `Provides`/`DefaultPort`/description), `textinput` (name/alias/param fields), a **`viewport` live-preview pane** that re-renders the would-be `workspace.yaml` on every keystroke, plus `spinner`/`progress`/`help`/`key`. `charm.land/huh/v2` is **embedded for the linear sub-forms** (per-service param entry) where a form is the right primitive — it is not the top-level driver. The layout is a cohesive two-pane `charm.land/lipgloss/v2` theme (picker left, live preview right) with adaptive color, rounded borders, and a persistent keymap footer — **one shared theme + model scaffolding across every devstack TUI**, owned by `internal/prompt`/`internal/tui` and reused by [spec 23](23-template-authoring.md)/[spec 24](24-env-ingestion.md). +- **New TUI dependencies: the v2 charm stack (all direct).** Add `charm.land/bubbletea/v2` + `charm.land/bubbles/v2` + `charm.land/huh/v2` on top of the already-vendored `charm.land/lipgloss/v2`/`charm.land/fang/v2` — all pure-Go, CGO-free, safe for the `CGO_ENABLED=0` static binary, pinned to the v2 line. Wrapped behind `internal/prompt` so the Bubble Tea runtime is never entered on a non-TTY. +- **Honors the global output contract.** Under `--json`, `--quiet`, `CI`, or a non-TTY stdin/stdout, the TUI **does not launch**: `init` either runs the flag-driven non-interactive path (if enough flags were given) or exits non-zero with a one-line "not a TTY — pass `--service`/`--name` or run in a terminal" guidance. `--json` emits a machine summary of what was written. + +## CLI surface +``` +devstack init [flags] + + # interactive (TTY): launches the huh wizard, ignores most flags as pre-fills + devstack init + + # non-interactive / scriptable (no TUI): + --name workspace name (default: basename of CWD, dsname-sanitized) + --service add a shared service; repeatable + e.g. --service postgres@16 --service redis --service minio + --param .= set/override one shared-service param; repeatable + --alias add a workspace alias; repeatable + --profile profiles.default (default: dev) + --project =[,git=] add a projects[] ref; repeatable + --scaffold-projects also write a minimal devstack.yaml stub per --project + --from-store seed the shared set from $DEVSTACK_HOME/config.yaml + --out output directory (default: CWD) + --dry-run print result + validation verdict, write nothing + --force overwrite existing files (backs up originals first) + --no-input never launch the TUI; require flags (implied by --json/--quiet/non-TTY/CI) + --accessible huh accessible mode (screen-reader friendly, no full-screen redraw) +``` + +The in-memory target is exactly `config.Workspace`: +```go +ws := config.Workspace{ + APIVersion: config.APIVersion, // "devstack/v1" (fixed) + Kind: config.KindWorkspace, // "Workspace" (fixed) + Name: name, // dsname-validated, live + Aliases: aliases, // each dsname + Profiles: config.Profiles{Default: profile}, + Shared: map[string]config.SharedSvc{ // key=service name (dsname) + "postgres": {Template: "postgres", Params: map[string]any{"version": "16"}}, + "redis": {Template: "redis"}, + "minio": {Template: "minio"}, + }, + Projects: projects, // []config.ProjectRef{Name,Path,Git} +} +// scaffold.EmitWorkspaceYAML(ws) -> ordered goccy bytes (NOT yaml.Marshal(ws)) +// config.ValidateWorkspaceBytes(b) / config.LoadWorkspaceOnly -> structural check BEFORE write +// (NOT config.validateModel — unexported; NOT config.LoadAt — loads project dirs that may not exist) +``` + +## Behavior +The pipeline is identical for both faces; only step 2 differs (TUI vs flags). + +1. **Detect + guard.** Resolve the output dir (`--out` or CWD). Check the **target dir** for an existing `workspace.yaml`; if present and neither `--force` nor `--dry-run` is set → refuse with "workspace.yaml already exists at ; use --force to overwrite or run from a fresh directory", exit non-zero. Separately, `config.Discover()` walks up for a *parent* workspace; if one is found above the target → refuse with "already inside a workspace at " (nested workspaces unsupported). Decide the mode: TUI iff `term.IsTerminal(stdin)&&term.IsTerminal(stdout)` **and** none of `--json`/`--quiet`/`--no-input`/`CI` are set; else non-interactive. +2. **Collect inputs.** + - **TUI path** (`internal/prompt`): a custom Bubble Tea v2 model with a left picker + right live `workspace.yaml` preview pane (embedded `huh` sub-forms for the linear groups) — + (a) **Name** input (validated live against `dsNameRE ^[a-z][a-z0-9_-]{0,62}$`, pre-filled with the sanitized CWD basename); + (b) **Shared services** multi-select listing `builtinSource().List()` filtered to non-empty `Provides`, each row showing name + `Provides`/`DefaultPort`/description (the `template list` render), default-checked from the store set if `--from-store`/store present; + (c) **per-service param group** for each picked engine, one field per `ParamSpec` (text for `string`/`int`, confirm for `bool`), required fields enforced, defaults pre-filled; + (d) optional **aliases** (repeatable text, each dsname-validated) and **profile** (default `dev`); + (e) optional **projects** (name=dsname, path, git) — opt-in screen, skippable. + - **Non-interactive path:** assemble the same struct from `--name`/`--service`/`--param`/`--alias`/`--profile`/`--project`, applying template defaults for any unset param and **failing fast** (mirroring `effectiveParams`) on a missing required param with no default. +3. **Assemble + default-overlay.** Build `config.Workspace`. For each shared service, overlay user params on top of the template defaults; **drop any param left at its default** so output stays minimal and diff-stable. Sort `shared` keys and `projects` deterministically. +4. **Validate in memory (structural).** Render via `scaffold.EmitWorkspaceYAML`, then run the bytes through `config.ValidateWorkspaceBytes` (or write to an **isolated** temp dir and call `config.LoadWorkspaceOnly` there, so Discover cannot escape upward to an unrelated parent). This checks the `apiVersion: devstack/v1` + `kind: Workspace` header and every `dsname`, without requiring project dirs to exist. On failure: TUI surfaces the `file:line:col`/field error inline and returns to the offending group; non-interactive prints the error and exits non-zero **without writing**. (Full `${ref}`/shared-graph validation runs at the next `generate`, once project repos are on disk.) +5. **Preview + confirm.** TUI shows the rendered YAML (+ "validates: ok") in a final confirm screen; `--dry-run` (either face) prints the same to stdout and stops. `--json` prints a summary object (`{workspace: , shared: [...], projects: [...], wrote: bool}`). +6. **Write (no-clobber, atomic).** `MkdirAll(out, 0o755)`; if a target exists and `--force`, copy it to `.bak.` first. Write `workspace.yaml` atomically (temp file in the same dir + `rename`, `0o644`) — the `store.Save`/`import` pattern. With `--scaffold-projects`, write each `projects[].path/devstack.yaml` stub (`apiVersion`/`kind: Project`/`name`/empty `services: {}`) under the same no-clobber rule. The file carries a leading `# Generated by \`devstack init\` — edit freely; re-run is safe with --force.` provenance comment. +7. **Report.** Print the written path(s) and a one-line next step (`run \`devstack up\` to start the shared stack`). Exit 0. + +No ledger or shared-stack mutation happens here — `init` is pure YAML authorship, so **no flock is taken** (same as `import`). + +## Verified constraints / gotchas +- **Add `charm.land/huh/v2`, NOT `github.com/charmbracelet/huh` (v1).** huh v2 is built on Bubble Tea v2 + Lip Gloss v2 (`charm.land/bubbletea/v2`, `charm.land/bubbles/v2`, `charm.land/lipgloss/v2`), the exact vanity stack already vendored (`charm.land/lipgloss/v2 v2.0.1`, `charm.land/fang/v2 v2.0.1`). The v1 module pulls bubbletea v1/lipgloss v1 and would **double-vendor a conflicting charm stack**. Pin to the v2 line (currently `charm.land/huh/v2 v2.0.3`) to stay byte-aligned. README import is literally `import "charm.land/huh/v2"`. +- **The whole charm terminal stack is CGO-free** — terminal I/O goes through `golang.org/x/sys` + `charmbracelet/x/term`/`x/ansi` + `ultraviolet`, all already vendored under `CGO_ENABLED=0`. huh adds no native deps. Safe for the single static binary across darwin/linux {amd64,arm64}; no build tags. Keep `make vuln`/govulncheck in CI after adding. +- **A TUI must never be the only path.** `form.Run()` errors when stdin is not a TTY; do not let that bubble as a crash. Gate on `golang.org/x/term.IsTerminal` (already a direct dep) **before** entering bubbletea and route to the flag path — `--json`/`--quiet`/`CI`/non-TTY must produce a deterministic result or a clear guidance error, never a half-drawn TUI. Offer `huh.WithAccessible()` via `--accessible` for screen readers. +- **Emit YAML via goccy ordered nodes, never `yaml.Marshal(struct)` / a `map`.** goccy randomizes map key order and renders `16` as `16.0` ([DECISIONS](../DECISIONS.md)); struct-marshal would also drop the fixed header ordering. Reuse the `internal/migrate` `MapSlice`/`MapItem` builder — extract it to `scaffold.EmitWorkspaceYAML` and have `migrate` call it, so there is exactly one emitter and its output is golden-tested for byte-stability. +- **Validate *before* writing — with the RIGHT entrypoint.** Use the EXPORTED `config.LoadWorkspaceOnly` (which structurally validates workspace.yaml and explicitly tolerates absent project dirs) or a new thin `config.ValidateWorkspaceBytes([]byte) error`. Do **NOT** use `config.validateModel` (it is unexported — lowercase, not callable from `internal/cli`/`internal/scaffold`) and do **NOT** use `config.LoadAt` (it reads every `projects[].path/devstack.yaml` and errors on a missing dir — wrong for an init where repos aren't cloned yet). This turns "produces a file that fails the next `generate`" into "fails in the wizard with an inline `file:line:col`". +- **Validate names live, per the real regex.** Workspace name, each shared key, alias, and project name must satisfy `dsNameRE ^[a-z][a-z0-9_-]{0,62}$`. Sanitize the CWD-basename default (lowercase, strip illegal chars) — a repo dir like `My.App` must become a valid pre-fill (`my-app`), not an invalid one the user must fix. +- **Only offer engines with a non-empty `Provides`.** A `SharedSvc` whose template lacks `Provides` isn't a shared engine (it's a project template) and would fail the shared-graph resolution in generation. Filter the picker on `template.Describe(...).Provides != ""`. +- **Enforce required params like `effectiveParams` does.** A chosen engine with a required, default-less param must block (TUI) or fail-fast (non-interactive) — emitting it unset produces a config that `generate` rejects. Conversely, drop params left at their template default so the file is minimal and re-running is diff-stable. +- **No-clobber is non-negotiable and atomic.** Overwriting a hand-tuned `workspace.yaml` silently is data loss; default-refuse, `--force` backs up first, write temp+rename so an interrupted run never leaves a truncated file (the `store.Save`/`import` contract). +- **`init` authors config; it does not touch shared state.** No ledger row, no network-ensure, no Docker call — therefore no flock, no Docker context dependency. It works with the daemon down (unlike `up`). This is what lets it ship in the v0.2 polish lane without the dind/integration lane. + +## Acceptance criteria +- [ ] `devstack init` on an interactive TTY launches the huh wizard, lets the user pick shared engines + fill per-`ParamSpec` params, and writes a `workspace.yaml` that `devstack generate` accepts unchanged (against a workspace whose project repos, if any, are present). +- [ ] The shared-service picker lists exactly the `builtinSource()` templates with a non-empty `Provides` (postgres/redis/minio + any store-custom engine) and hides `php.*`/`node.*`. +- [ ] `devstack init --name app --service postgres@16 --service redis --service minio` (no TTY) writes a valid `workspace.yaml` with no prompts, deterministic byte-for-byte across runs. +- [ ] Under `--json`, `--quiet`, `CI`, or a non-TTY stdin/stdout, the TUI never launches; `init` either completes from flags or exits non-zero with TTY/flag guidance. `--json` emits a machine summary. +- [ ] A required, default-less param left unset fails fast (non-interactive) / blocks the form (TUI); a param left at its template default is omitted from the emitted file. +- [ ] Output is emitted via `scaffold.EmitWorkspaceYAML` (goccy `MapSlice`) with fixed key order and integer params rendered as `16`, not `16.0`; `internal/migrate` uses the same emitter (one builder, golden-tested). +- [ ] The rendered bytes pass `config.LoadWorkspaceOnly`/`config.ValidateWorkspaceBytes` (structural) **before** any file is written; a malformed selection is reported in-wizard / pre-write, never deferred to `generate`. (No use of the unexported `validateModel` or of project-loading `LoadAt`.) +- [ ] `init` refuses to overwrite the target dir's `workspace.yaml` without `--force`; with `--force` it backs up the original first; it also refuses when `Discover` finds a parent workspace; writes are atomic (temp+rename, `0o644`). +- [ ] `--dry-run` prints the rendered file(s) + a "validates: ok" verdict and writes nothing. +- [ ] `--from-store` seeds the default selection from `$DEVSTACK_HOME/config.yaml` (`store.Load`, `ok=false` ⇒ no seed) when present; `init` never writes the store. +- [ ] Adding `charm.land/huh/v2` keeps `make ci` green under `CGO_ENABLED=0` and `make vuln` clean; the static cross-build for darwin/linux {amd64,arm64} still succeeds. + +## Dependencies / consumers +Consumes `internal/config` (the `config.Workspace`/`SharedSvc{Template,Params}`/`ProjectRef{Name,Path,Git}` target schema, `APIVersion`/`KindWorkspace`, `dsNameRE`, `Discover`, and the **workspace-only** validators `LoadWorkspaceOnly` / new `ValidateWorkspaceBytes` — explicitly NOT the unexported `validateModel` nor project-loading `LoadAt`), `internal/template` (`Describe`/`ParamSpec` for the param forms, the `Provides` filter, `effectiveParams` semantics), `internal/cli` `builtinSource()` (the live template catalogue), `internal/store` (`store.Load() (*store.Config, ok bool, err error)` + `DefaultConfig` for the `--from-store` seed — treat `ok=false` as "no store, no seed"), and `internal/xdg` (output-dir + store-path resolution). New: `internal/scaffold` (`EmitWorkspaceYAML`, shared with — and refactored into — `internal/migrate`, plus possibly the `ValidateWorkspaceBytes` helper landing in `internal/config`) and `internal/prompt` (the huh wrapper + non-TTY fallback, behind an interface so unit tests drive the flag path without a PTY). Wired into `NewRootCmd.AddCommand` as a vanilla cobra command taking `*GlobalOpts` (so fang stays removable). New module deps `charm.land/bubbletea/v2` + `charm.land/bubbles/v2` + `charm.land/huh/v2` (all direct, v2 line). Pairs with [spec 09](09-orchestration-and-onboarding.md) (the `up` onboarding promise + its planned bubbletea checklist — `init` is the file-authoring front door that precedes the first `up`, and shares the v2 charm stack) and [spec 14](14-self-update-and-migration.md) `import` (the *other* `workspace.yaml` emitter, now sharing one ordered builder). Git/OCI template sources for the picker arrive with [spec 19](19-template-registry.md). + +## Open questions +- **Scope of v1: shared-only, or also `projects[]` + per-repo stubs?** Authoring `projects[]` (name/path/git) and scaffolding minimal `devstack.yaml` stubs adds real form surface but is what makes the output a *complete* workspace ([spec 01](01-config-schema.md)). **Decision: shared-services-first; projects opt-in (TUI screen + `--project`/`--scaffold-projects`), no project *service* authoring (that stays the per-repo file's job).** +- **Does `init` also offer to write/update the global store?** Tempting (one wizard for both), but it conflates two files with different lifecycles. **Decision: read the store as a seed, never write it.** +- **Where does the ordered emitter live?** A new `internal/scaffold` vs folding into `internal/config`. **Decision: `internal/scaffold.EmitWorkspaceYAML`, and refactor `internal/migrate` onto it.** The byte-level *validator* (`ValidateWorkspaceBytes`), by contrast, belongs in `internal/config` next to `LoadWorkspaceOnly` since it needs the unexported `structValidate`. +- **huh v2 vs a zero-dep `x/term` prompt loop.** A hand-rolled loop adds no dependency but reinvents validation/multi-select/redraw and diverges from the [spec 09](09-orchestration-and-onboarding.md) bubbletea intent. **Decision: `charm.land/huh/v2`, behind `internal/prompt` with a non-TTY fallback.** \ No newline at end of file diff --git a/docs/specs/23-template-authoring.md b/docs/specs/23-template-authoring.md new file mode 100644 index 0000000..1c1fcc3 --- /dev/null +++ b/docs/specs/23-template-authoring.md @@ -0,0 +1,200 @@ +# Spec 23 — Interactive template & Dockerfile authoring (TUI) + +**Module:** `internal/prompt` (new) · `internal/template/scaffold` (new) · `internal/cli` (`template new` / extended `template init`) · **Milestone:** M8 (post-GA polish lane) · **Effort:** ~2.5w + +## Purpose +Let an author scaffold a new service template — `template.yaml` + an optional `build/` tree (Dockerfile, entrypoint, conf) + an optional golden fixture — **interactively**, instead of hand-writing the on-disk bundle from the one-paragraph guide in `templates/README.md`. The TUI is a thin front-end over the M1 substrate that already exists ([spec 02](02-templating-and-generation.md)): it prompts for the same fields the typed `Manifest` ([internal/template/model.go]) carries, renders a live preview through the **real** `template.Resolve` → `generate.LintResolved` path, and writes the bundle atomically to the global `$DEVSTACK_HOME/templates` store (`internal/store`, the override-by-name custom-templates dir). It is **strictly additive**: it produces nothing the engine can't already consume, introduces no new template feature, and every interactive flow has a flag-driven / `--json` equivalent so CI and scripts never touch the bubbletea runtime. + +This is a *quality-of-onboarding* feature, not a pipeline change. The hard rules of [spec 02](02-templating-and-generation.md) (custom `[[ ]]` delimiters, deterministic FuncMap, compose-go validation, byte-stable output) are the **acceptance gate** the authored bundle must clear, not something this spec relaxes. + +## Decisions +- **New verb `template new`, not a rewrite of `template init`.** `template init` stays exactly as it is today (it scaffolds a fixed `template.yaml` + `build/Dockerfile` skeleton, single positional arg, `--quiet`-safe, no TTY needed, refuses to overwrite an existing dir) — scripts and the existing acceptance depend on it. `template new` is the interactive sibling. A bare `template new` with no TTY (piped stdin, `CI` set, `--json`, `--quiet`) **degrades to the same scriptable path as `init`**: it reads answers from flags/`--from` and errors with a clear "no TTY; pass `--name`/`--from` or use `template init`" message rather than hanging on a prompt. +- **The TUI is the *data-entry* layer only.** Every prompt fills one field of an in-memory `scaffold.Spec`; the spec is then handed to a **single pure function** `scaffold.Build(Spec) (Bundle, error)` that the non-interactive path *also* calls. The form and the flag-parser are two front-ends to one builder — there is no behavior reachable only through the TUI (the headline-output contract, [ARCHITECTURE §7.9](../ARCHITECTURE.md)). +- **The TUI is a custom Bubble Tea v2 program with a live two-pane preview — modern aesthetics are a first-class requirement** (owner directive: "a really nice modern TUI for CLIs"). It is a hand-written `charm.land/bubbletea/v2` model built from `charm.land/bubbles/v2` components — `list` (the kind / `extends`-parent / base-image pickers, each row annotated with the parent's `provides`/`exports`/`params`), `textinput`/`textarea` (name, description, param fields, Dockerfile body), and a **`viewport` live-preview pane** rendering the generated `template.yaml` + Dockerfile + compose fragment as you type (the two-pane layout shown in the worked example). `charm.land/huh/v2` is **embedded for the linear param-entry sub-forms** only — it is not the top-level driver. Styling is the shared `charm.land/lipgloss/v2` theme owned by `internal/prompt`/`internal/tui` (adaptive color, rounded borders, keymap footer), the **same theme as [spec 22](22-init-wizard.md)** so every devstack TUI looks like one product. All deps are direct, pure-Go, pinned to the v2 line, byte-aligned with the already-vendored `charm.land/lipgloss/v2 v2.0.1` + `charm.land/fang/v2 v2.0.1`, and CGO-free (see gotchas). +- **App-vs-engine is the first branch.** A buildable **app** template emits `service.build{context:build,dockerfile:Dockerfile}` + a `build/Dockerfile` and **no `provides:`**. A shared **engine** template emits `image:` + `provides`/`exports`/`defaultPort`/`volumes` and **no `build/` tree** — because `generate.buildSharedService` rejects any `build:` key on a shared service ([internal/generate/compose.go]). The wizard refuses to let you mix the two. +- **The builder writes deterministically.** `scaffold.Build` produces a `Bundle` (a `map[relpath][]byte`), emitted byte-stably (sorted, fixed newlines, LF, no trailing whitespace) via the same `writeIfChanged` (same-dir temp + `os.Rename`, `0644`) pattern as `internal/generate`. The same `Spec` always yields byte-identical files — no clock/uuid/random anywhere (there are none in the FuncMap by design; `engine.go` documents the omission explicitly). +- **Meta fields are written as literal YAML, never templated.** `schemaVersion/extends/description/provides/exports/defaultPort/params` are parsed *unrendered* by `parseMeta` ([model.go] discards `service:`/`volumes:` from the meta parse); the builder emits them as plain scalars and **never inserts a `[[ ]]` action into a meta key**. Only `service:`/`volumes:` blocks (and `build/` files) may carry `[[ .params.* ]]`. +- **Live preview uses the production path, not a mock.** As the author edits, the preview pane calls `template.Resolve(memSource, name, nil)` → `generate.LintResolved(name, res)` — the exact render-with-defaults + compose-go/v2 validation that `template lint` runs (see `internal/cli/template.go` `lintTemplateDir`, which is the identical `Resolve`→`LintResolved` chain). What you preview is what `generate` will emit. No second renderer. +- **Lock-free, store-local, secret-free.** Authoring writes only files under `$DEVSTACK_HOME/templates//`. It never touches the SQLite ledger, never takes the flock, never starts Docker, and never resolves a `secret://` ref (templates declare param *names/defaults*, not secret values — same property as `template init` today). No leak surface is added; the no-plaintext-secret-on-disk CI leak-test ([spec 04](04-secrets.md)) is not even in scope because nothing here can emit a secret value. +- **A golden fixture is scaffolded (opt-in, default on for `new`).** When the author opts in, the builder runs the preview render once and writes it to `/golden.yaml`, so `template test ` has something to byte-assert from day one. The real `template init` writes **no** golden today (it emits only `template.yaml` + `build/Dockerfile`), and `template test` already compares `/golden.yaml` byte-for-byte when present (`internal/cli/template.go`) — so this closes a genuine gap, not a hypothetical one. The golden is regenerable: `template new --regold ` re-renders and rewrites it. +- **`params.schema.json` stays deferred.** [spec 19 / D16](../DECISIONS.md) says the editor schema travels with a template, but it's a v2 registry concern; the wizard does **not** emit one in this milestone (one less thing to keep in sync, and nothing in v1 reads it — `template init`/`lint`/`test` do not produce or consume a schema today, despite spec 19's prose). Tracked as an open question below. + +## CLI surface +``` +devstack template new [name] # interactive when stdin is a TTY; else flag-driven + --kind app|engine # app ⇒ build/Dockerfile, no provides; engine ⇒ image:, provides: + --name # template name (single path segment; dots ok, no slash/"..") + --from # non-interactive: read a scaffold.Spec (YAML) instead of prompting + --extends # parent template to extend (app or engine) + --description + --base-image # app: FROM ; engine: image: + --param name:type[:default][:required] # repeatable; type ∈ string|int|bool + --port # engine: defaultPort; app: published-port hint (advisory) + --provides # engine only + --exports k1,k2,... # engine only + --entrypoint # app: also scaffold build/entrypoint.sh + --golden / --no-golden # scaffold /golden.yaml (default: on) + --regold # re-render the golden for an existing template, write nothing else + --dir # parent dir (default: $DEVSTACK_HOME/templates, == store.TemplatesPath()) + --dry-run # print the would-be bundle as a tree + per-file diff, write nothing + --force # overwrite an existing template dir (backs up first) + --print-spec # emit the resolved scaffold.Spec as YAML to stdout and exit (for --from round-trip) +``` +- `template init` is **unchanged** and remains the documented scriptable minimum (its existing `--dir` default is `store.TemplatesPath()`; `new` matches it). +- `--json` makes `new` non-interactive and machine-readable: it requires `--name`/`--kind` (or `--from`), runs the builder, and prints `{"template":"","path":"...","files":[...],"wrote":bool}`. No prompts, no footer, no bubbletea. + +## Behavior +The interactive flow (TTY path) is a custom Bubble Tea v2 model with a live preview pane (embedded `huh` sub-forms for linear param entry) whose every input maps to a `scaffold.Spec` field; the builder pipeline below is identical on the non-interactive path (flags/`--from` populate the same `Spec`). + +1. **Kind & name.** Prompt `kind` (app / engine), then `name`. The name input validates live against the same rule as `source.validRef` — single path segment, dots allowed (`php.laravel.nginx`), **no slash, no `..`, not absolute** (`validRef` requires `ref == path.Base(ref) && !filepath.IsAbs(ref)`) — and rejects a name that already resolves in the store/built-in source unless `--force`. +2. **Extends (optional).** Offer an "extends parent" picker populated by `template.Describe` over the store ∪ embedded source. Selecting a parent shows its `provides`/`exports`/`params` so the author sees what they inherit. Surface the [spec 02](02-templating-and-generation.md) deep-merge gotcha inline: **lists REPLACE by default**; offer a checkbox to emit `$merge: append` on list keys the child overrides. +3. **Base image & description.** For an app: `FROM `; for an engine: `image: `. Free-text description. +4. **Params loop.** Repeatedly add params: `name`, `type` (string|int|bool — advisory only, [model.go] `ParamSpec.Type`), `default`, `required` (bool). Required params with no default are flagged; the builder enforces "a referenced-but-undeclared param is a hard error" by construction (every `[[ .params.X ]]` it writes corresponds to a declared param — `Option("missingkey=error")` in `engine.go` would otherwise fail the preview, and `effectiveParams` fails fast on a missing *required* value). +5. **Engine specifics** (kind=engine only): `provides` (capability string), `exports` (multi-select / comma list), `defaultPort`, optional named `volumes`. **No `build/` is offered** — choosing engine disables the Dockerfile step entirely. +6. **App specifics** (kind=app only): optionally add `build/entrypoint.sh` and arbitrary extra `build/*.conf` files. The Dockerfile is pre-seeded with a `# syntax=docker/dockerfile:1` header and a literal-`$`-safe body where only `[[ .params.* ]]` is templated. +7. **Build → preview.** On every change (and on the final "Review" screen) the wizard calls `scaffold.Build(Spec)` → `template.Resolve` → `generate.LintResolved`. The preview pane shows (a) the rendered single-service compose document and (b) compose-go/v2 validation status. A render or validation error is shown in-pane and **blocks the write button** — you cannot scaffold a bundle that wouldn't generate. +8. **Lint checks.** Before write, run the authoring lints. These are **new** checks: `LintResolved` today performs only compose-go validation, and `internal/generate/lint.go` implements only the workspace alias-collision guardrail — neither the delimiter-collision nor the param-type check exists yet, even though [spec 19](19-template-registry.md) attributes them to `template lint`. This spec adds: **delimiter-collision** (a literal `[[`/`]]` in a `build/` file that isn't a known action → warn), **param-type** (a `default` that doesn't parse as its declared `type` → warn), and **meta-templating** (a `[[ ]]` action in a meta key → hard error). These also run inside the live preview, and should be wired into `template lint` proper so authored and registry-consumed bundles share one check set. +9. **Confirm & write.** The Review screen prints the bundle as a file tree. On confirm, `Bundle` is written under `--dir//` with `writeIfChanged` (atomic, `0644`); refuses an existing dir unless `--force` (which backs up the prior dir to `.bak./` first). `--dry-run` prints the tree + a unified diff against any existing files and writes nothing. +10. **Golden.** Unless `--no-golden`, the builder writes `/golden.yaml` = the previewed compose, so `template test ` passes immediately and guards future edits. +11. **Footer / next step.** On success print the path and the exact follow-ups: `template lint `, `template test `, and how to reference it from a repo's `devstack.yaml` (`services: { app: { template: } }`). The wizard does **not** auto-edit a project's `devstack.yaml` in v1 (open question below). + +```go +// internal/template/scaffold — one builder, two front-ends (TUI + flags) feed it. +package scaffold + +type Kind string // "app" | "engine" + +type Param struct{ Name, Type, Default, Description string; Required bool } + +type Spec struct { + Kind Kind + Name string // validated against source.validRef + Extends string // optional parent ref + Description string + BaseImage string // app: FROM; engine: image: + Params []Param + // engine-only: + Provides string + Exports []string + DefaultPort int + Volumes []string + // app-only: + Entrypoint bool + ExtraBuild []string // extra build/ files to seed + Golden bool +} + +type Bundle map[string][]byte // relpath ("template.yaml","build/Dockerfile",…) → bytes, byte-stable + +func Build(s Spec) (Bundle, error) // pure, deterministic; emits meta as literal YAML, [[ ]] only in service/build +``` + +```go +// internal/prompt — thin interface over huh/v2 so non-TTY paths never enter bubbletea. +package prompt + +type Asker interface { + // Run returns ErrNoTTY when stdin is not a terminal; callers fall back to flags/--from. + Run(ctx context.Context, form *Form) error +} +func IsInteractive(in *os.File) bool // term.IsTerminal && !CI && !--json && !--quiet +``` + +## Verified constraints / gotchas +- **Use `charm.land/huh/v2`, NOT `github.com/charmbracelet/huh` (v1).** The v1 module pulls bubbletea v1 + lipgloss v1 and would double-vendor a *conflicting* charm stack against the already-vendored `charm.land/lipgloss/v2 v2.0.1` + `charm.land/fang/v2 v2.0.1`. huh v2 (currently `v2.0.3`) is built on bubbletea v2 / lipgloss v2 and imports as `charm.land/huh/v2`; add its transitive `charm.land/bubbletea/v2` + `charm.land/bubbles/v2` and pin the whole set to the v2 line. Keep `make vuln` (govulncheck) in CI after adding. +- **huh/bubbletea v2 are cgo-free — the static build holds.** All terminal I/O goes through `golang.org/x/sys` + `charmbracelet/x/term` + `x/ansi` + `ultraviolet`, all already vendored (`go.mod` already carries `charmbracelet/ultraviolet`, `x/term`, `x/ansi`, `x/termios`, `x/windows` as indirect deps of fang/lipgloss) under `CGO_ENABLED=0`; huh adds no native deps beyond that family. No build tags, works across `darwin/{amd64,arm64}` + `linux/{amd64,arm64}` from the one Linux runner. +- **A TUI must never be the only path.** `form.Run()` returns an error when stdin is not a TTY; gate every interactive entry on `term.IsTerminal(os.Stdin.Fd()) && os.Getenv("CI")=="" && !json && !quiet` and fall back to flags/`--from`. huh also has `.WithAccessible()` for screen-reader mode — wire it, but it is not the non-TTY fallback. This is the [ARCHITECTURE §7.9](../ARCHITECTURE.md) headline-output contract: `--json`/`--quiet`/non-TTY/`CI` must reach the builder without touching bubbletea. +- **Determinism is the acceptance gate, not a nicety.** The builder must emit byte-identical bytes for a given `Spec`, and the *rendered* bundle must re-render byte-identically (`make determinism`). Naive "throw a timestamp/UUID into the scaffolded comment header" is wrong — there is no `now`/`uuid`/`random`/`env` in the FuncMap on purpose (`funcs.go`/`engine.go` document the omission), and `missingkey=error` turns a referenced-but-undeclared param into a hard failure, not an empty string. The wizard must never offer a non-deterministic helper. +- **Don't template meta keys.** `parseMeta` reads `schemaVersion/extends/description/provides/exports/defaultPort/params` *before* rendering and **discards `service:`/`volumes:`** from that parse (re-reading them from the rendered document); a `[[ ]]` action in a meta key is silently un-rendered garbage. The builder emits meta as literal YAML and confines actions to `service:`/`volumes:`/`build/` — and the meta-templating lint (step 8) makes a stray action a hard error. +- **Engine templates must be image-based.** `generate.buildSharedService` rejects *any* `build:` key on a shared service ("shared services must be image-based; build contexts are not supported"), so the wizard must structurally prevent an engine kind from getting a `build/` tree (it's a compile-time branch in `scaffold.Build`, not a runtime check the author can bypass). +- **Keep author `$`-syntax literal.** The seeded Dockerfile/entrypoint must keep `$VAR` / `${VAR:-default}` / `$TAG` untouched and use `[[ ]]` only for param substitution — this is [spec 02](02-templating-and-generation.md) acceptance #2 (delimiter non-collision), exercised by `php.nginx/build/Dockerfile`. The delimiter-collision lint warns if a `build/` file contains a bare `[[`/`]]` that isn't a known action. +- **Name validation must mirror `source.validRef` exactly.** Single path segment, dots allowed, no slash, no `..`, not absolute. A wizard that accepts `my/app` writes a directory the source layer can never resolve. +- **Write atomically, never clobber silently.** Same-dir temp + `os.Rename`, `0644`; refuse an existing template dir without `--force`; with `--force`, back up first — mirroring `template init`'s refuse-to-overwrite and `internal/generate`'s `writeIfChanged`. +- **Lock-free and ledger-free by construction.** [spec 19](19-template-registry.md) states `init`/`lint`/`test` are read-only/local and take no flock; `template new` inherits that — it writes only `$DEVSTACK_HOME/templates` files and must not import `internal/state` or `internal/lock`. + +## Worked example +`devstack template new` (TTY), authoring a buildable Node app template called `node.bun`: + +``` +? Kind › app (buildable) ▌preview ─────────────────── +? Name › node.bun ▌services: +? Extends (optional) › (none) ▌ node.bun: +? Base image › oven/bun:1 ▌ build: +? Description › Bun + Vite dev server ▌ context: build ++ Param name=bunVersion type=string default="1" required=false ▌ dockerfile: Dockerfile ++ Param name=port type=int default="5173" ▌ restart: unless-stopped +[x] Scaffold build/entrypoint.sh ▌ environment: +[x] Write golden.yaml ▌ NODE_ENV: development + ▌ ✓ compose-go: valid +``` + +On confirm the builder writes (byte-stable): + +``` +$DEVSTACK_HOME/templates/node.bun/ +├── template.yaml +├── build/ +│ ├── Dockerfile +│ └── entrypoint.sh +└── golden.yaml +``` + +`template.yaml` (meta literal, actions only under `service:`): +```yaml +schemaVersion: 1 +description: "Bun + Vite dev server" +params: + bunVersion: + type: string + default: "1" + port: + type: int + default: "5173" +service: + build: + context: build + dockerfile: Dockerfile + args: + BUN_VERSION: "[[ .params.bunVersion ]]" + restart: unless-stopped + environment: + NODE_ENV: development +``` + +`build/Dockerfile` ( `$`-syntax literal, `[[ ]]` only for the param): +```dockerfile +# syntax=docker/dockerfile:1 +FROM oven/bun:[[ .params.bunVersion ]] +WORKDIR /app +COPY build/entrypoint.sh /entrypoint.sh +# author shell stays literal — ${PORT:-5173} is NOT a template action: +ENTRYPOINT ["/entrypoint.sh"] +``` + +The same bundle is reproducible headlessly — the round-trip the CI determinism check exercises: +``` +devstack template new --print-spec --kind app --name node.bun --base-image oven/bun:1 \ + --param bunVersion:string:1 --param port:int:5173 --entrypoint > spec.yaml +devstack template new --from spec.yaml --json # byte-identical bundle, no TTY +``` + +## Acceptance criteria +- [ ] `template new` on a TTY walks the wizard and writes a valid bundle; the same inputs via `template new --from spec.yaml` (no TTY) produce a **byte-identical** directory. +- [ ] `template new` with no TTY / `--json` / `--quiet` / `CI` set never enters bubbletea; it either runs from flags/`--from` or exits with a "no TTY; pass `--name`/`--from`" error — it never hangs on a prompt. +- [ ] An authored app bundle passes `template lint ` and `template test ` immediately (the scaffolded `golden.yaml` matches the rendered output byte-for-byte). +- [ ] Choosing `--kind engine` emits `image:`/`provides`/`exports`/`defaultPort` and **no `build/` tree**; the wizard structurally prevents an engine from getting a `build:` key (would be rejected by `generate.buildSharedService`). +- [ ] A name with a slash, `..`, or leading `/` is rejected with the same rule as `source.validRef`; an existing template dir is not overwritten without `--force` (which backs up first). +- [ ] No scaffolded file contains a non-deterministic value; `make determinism` stays green and `scaffold.Build` is shown idempotent by a unit test (same `Spec` → same bytes). +- [ ] A `[[ ]]` action placed in a meta key is a hard lint error; a literal `[[`/`]]` in a `build/` file that isn't a known action warns (delimiter-collision); a `default` that doesn't parse as its declared `type` warns. +- [ ] The live preview renders through `template.Resolve` + `generate.LintResolved` and blocks the write button on a render/validation error. +- [ ] Authoring writes only `$DEVSTACK_HOME/templates` files; it imports neither `internal/state` nor `internal/lock`, starts no Docker, and resolves no `secret://` ref. +- [ ] `template init` behavior, flags, and output are unchanged. + +## Dependencies / consumers +Consumes `internal/template` (`Resolve`/`Describe`/`source.validRef`/the FuncMap), `internal/generate` (`LintResolved` for preview, `writeIfChanged` pattern), `internal/store` (`TemplatesPath` emit target + override-by-name wiring for the global `$DEVSTACK_HOME` store), and the new `internal/prompt` (huh/v2 wrapper) + `internal/template/scaffold` (the builder). New deps (all direct, v2 line): `charm.land/bubbletea/v2` + `charm.land/bubbles/v2` + `charm.land/huh/v2` (currently `v2.0.3`), pinned alongside the vendored `charm.land/lipgloss/v2 v2.0.1` / `charm.land/fang/v2 v2.0.1`. Consumed by `internal/cli` (`template new`). Feeds the [spec 19](19-template-registry.md) registry work: a bundle authored here is exactly the shape a future `template publish`/bundle target packages, and the new delimiter-collision/param-type lints should land in shared lint code both `template new` and the spec-19 `template lint` call. + +## Open questions +- **`params.schema.json` emission.** [spec 19 / D16](../DECISIONS.md) wants the editor schema to travel with a template, but v1 reads no such file (and `template init`/`lint`/`test` neither emit nor consume one today, despite spec 19's prose). Recommendation: defer — emit nothing now, add a `--with-schema` flag when the registry lands so the schema and the bundle stay in one owner. **Decision:** deferred to the registry milestone; not in this spec. (Spec 19's `template init` description should be reconciled to match the actual implementation.) +- **Auto-wire into `devstack.yaml`.** Should `template new` offer to add a `services: { app: { template: } }` block to the nearest `devstack.yaml` so the app is immediately usable? Recommendation: keep authoring and project-wiring separate in v1 (a template is reusable across repos; wiring is a per-repo edit) — stop at the template dir, print the snippet to copy. **Decision:** print-only in v1; an opt-in `--wire ` is a fast-follow. +- **Scaffolding a new *built-in* (`templates/` + `embed.go`).** A store template needs no code change, but a built-in requires a manual `go:embed` edit + recompile. Recommendation: `template new` always targets the store; promoting a store template to a built-in stays a deliberate maintainer PR, not a wizard action. **Decision:** store-only target; built-in promotion is out of scope. +- **How much merge-semantics UI for `extends`.** Surfacing the list-replace-vs-`$merge: append` gotcha ([spec 02](02-templating-and-generation.md)) interactively risks overwhelming a first-time author. Recommendation: show it only when the child actually overrides a list key the parent defines, with a one-line explainer + a checkbox. **Decision:** contextual, not always-on. \ No newline at end of file diff --git a/docs/specs/24-env-ingestion.md b/docs/specs/24-env-ingestion.md new file mode 100644 index 0000000..260e7de --- /dev/null +++ b/docs/specs/24-env-ingestion.md @@ -0,0 +1,155 @@ +# Spec 24 — `.env` ingestion → secrets & config vars + +**Module:** `internal/envingest` (new) · `internal/secrets` (new write capability) · `internal/prompt` (new non-TTY-degradable TUI wrapper) · `internal/cli` (`secrets ingest`) · **Milestone:** M8 — post-M7 polish lane (0.x line; ships in a later 0.x minor, **not** v1.0) · **Effort:** ~2.5w + +## Purpose +Get committed `.env` files out of the repo. One command reads an existing `.env`, **classifies each key** secret vs non-secret, **encrypts the secret half** into a SOPS+age file (or pushes it to an already-configured provider) so **no plaintext ever lands on disk or in committed config**, **inlines the non-secret half** as ordinary config vars, **rewrites `devstack.yaml`** so every former env value becomes a literal config value or a `secret://` ref ([spec 04](04-secrets.md)), and then fences/deletes the original `.env`. The output is byte-for-byte the env shape the existing **read** path ([spec 04](04-secrets.md) injection, [spec 02](02-templating-and-generation.md) §7.5 coupling) already resolves — `internal/generate/secrets.go SecretRefs` already scans `env.raw`/`env.prefixed` for `secret://` values and emits valueless keys — so this spec builds only the missing **write** side. It mirrors the `devstack import` UX contract ([spec 14](14-self-update-and-migration.md)): dry-run that writes nothing, no-clobber without `--force`, and a loud conversion report. + +## Decisions +- **New module `internal/envingest`** owns the pipeline (parse → classify → route → encrypt/push → rewrite → fence). The secret-vs-config policy and the YAML rewrite live here, not in `internal/secrets` (which stays a thin provider boundary) and not in `internal/cli`. +- **Reuse the Compose dotenv parser** — `github.com/compose-spec/compose-go/v2/dotenv` (already in `go.mod` via `compose-go v2.12.1`). `Parse(io.Reader) (map[string]string, error)` (or `ParseWithLookup` if source-side `${VAR}` interpolation must be honored). **Zero new parser dependency**, and byte-for-byte parity with how `docker compose` reads `.env` (quoting, multiline, `export ` prefix, comments). Do **not** add `joho/godotenv` or `hashicorp/go-envparse`. **Note:** `Parse` returns an *unordered* `map`, so source order is not recoverable from it; the report, the encrypted payload, and the rewritten YAML are emitted in **sorted-key order** for determinism (the same `sortedKeys` discipline `internal/generate` uses) — not "source order". +- **Default destination = a SOPS+age file** (`secrets.enc.yaml` at the workspace root) — the offline, no-account, "works on a plane" path, identical to spec 04's default provider. Pushing to AWS/Infisical is **opt-in** via `--to aws-sm|infisical` and requires the new write capability below. +- **SOPS is encrypted via `sops --encrypt` over stdin**, never a plaintext temp file in the repo. The assembled plaintext map is YAML-marshalled and piped to `sops --encrypt --input-type yaml --output-type yaml /dev/stdin --age `; stdout (ciphertext) is written via `writeIfChanged`. Output-type **yaml/json**, not dotenv (dotenv flattens to flat string `KEY=VALUE` and can't express nested keys — the existing read path resolves `secret://sops/secrets.enc.yaml#postgres.password` dot-paths, and the decrypt side already shells `sops -d --output-type json`). Encryption needs only the **recipient** (`age1…` public key); no private key is read for the write half. +- **Keep shelling out to the `sops` binary; do NOT vendor `getsops/sops/v3`.** `internal/secrets/sops.go` deliberately shells `sops -d` (its header: "shells out to the `sops` binary (NOT the getsops Go SDK)") because the Go SDK pulls all cloud-KMS SDKs transitively (binary bloat, larger surface against `CGO_ENABLED=0`). The encrypt half follows the same rule: `exec.Command("sops", "--encrypt", …)` with plaintext on stdin. +- **The `Provider` interface gains an optional `Pusher`** (capability interface, not a contract change): `internal/secrets` adds `type Pusher interface { Push(ctx, []SecretEntry) error }`; the AWS/Infisical providers implement it where a write CLI exists (`aws secretsmanager put-secret-value` / `aws ssm put-parameter` / `infisical secrets set`). The current `Provider` interface is `Resolve`-only — this write capability is **net-new code**. The SOPS provider is the default, no-account, write-to-file path and needs no `Pusher`. Providers without a writer stay `Resolve`-only and ingest refuses `--to` against them with a one-line reason. +- **Classification is policy-driven, default-deny (safe):** an unknown key is treated as **secret**. Order of precedence: explicit `--public`/`--secret` glob lists → name-pattern matcher (extends the existing `secretAttrs` seed) → value-shape heuristics (high-entropy, credentialed URL, PEM/JWT shape) → interactive confirm (TTY) → default secret. +- **Non-secret (config) keys default to an inline literal**, **not** `${env.}`. The source of those values is the `.env` file, which this command deletes — so a `${env.}` interpolation would resolve to empty at the next `generate`. `${env.}` is therefore **opt-in only** for keys the operator marks as still supplied by the ambient host/CI environment (wizard toggle or `--from-host `); the default for every non-secret key is to write its literal value verbatim into `env.raw`. +- **Interactive wizard is a Bubble Tea v2 model behind `internal/prompt`**, reusing the shared `internal/prompt`/`internal/tui` theme and components from [spec 22](22-init-wizard.md)/[spec 23](23-template-authoring.md) so it looks like one product. The per-key classification UI is a `charm.land/bubbles/v2` **`table`** (one row per `.env` key: name · class · reason · emitted ref/literal) with inline secret/config toggles, over a `charm.land/lipgloss/v2` theme; `charm.land/huh/v2` is embedded for the destination/recipient sub-form. The wizard is **never the only path**: `--yes`/`--json`/`--quiet`/non-TTY/`CI` all bypass it and use the computed classification + flag overrides. `internal/prompt` gates on `term.IsTerminal` and falls back to non-interactive **before** the Bubble Tea runtime ever starts. +- **`devstack.yaml` rewrite is position/comment-preserving** via the goccy AST (config already parses with `goccy/go-yaml`, which exposes positions): only the touched `environment` values are replaced; the `apiVersion: devstack/v1` header, key order, and comments survive. This is a committed, hand-edited file, so the rewrite obeys no-clobber + backup like `import`. +- **Ensure the destination provider is declared.** A `secret://sops/…` ref only resolves if a provider instance named `sops` exists in `workspace.yaml` (`secrets.providers[]`). Ingest checks for it and, if absent, **scaffolds a default `sops` provider entry** (pointing at the `$DEVSTACK_HOME` age key file) into `workspace.yaml` — backing that file up too — so the round-trip resolve in step 7 can succeed. If the provider exists but its name differs, ingest uses the declared name in the emitted refs. +- **No flock is taken in any mode.** The #1 rule governs mutations of the **ledger** or the **shared stack** ([CLAUDE.md](../CLAUDE.md)); a project-local ingest (write `secrets.enc.yaml` + rewrite `devstack.yaml`/`workspace.yaml`) touches neither, and a remote `--to aws-sm|infisical` push touches neither (it mutates an external secrets store, which a local advisory flock cannot protect anyway). Remote-push idempotency relies on the provider's put/overwrite semantics, not a lock. +- **`.env` is fenced before it is removed.** Ingest appends `.env` to `.gitignore` (marker-fenced), **refuses to proceed if `.env` is already git-tracked** (the plaintext is already in history — a different remediation), re-resolves every newly written ref to prove round-trip, and only then deletes `.env` (or, with `--keep-env`, leaves it and just prints the deletion command). The age **private** key lives under `$DEVSTACK_HOME` (outside the repo tree), so it needs no repo `.gitignore` entry; the fence covers an in-repo key file only if one was created there. + +## Reference & data shapes +```go +package envingest + +type Class int +const ( ClassSecret Class = iota; ClassConfig ) + +type Decision struct { + Key string // e.g. DB_PASSWORD + Class Class + Reason string // "name matches *PASSWORD", "--public override", "high-entropy value", "default-deny" + Ref string // emitted value: secret://sops/secrets.enc.yaml#DB_PASSWORD (secret) + // or the inline literal (config, default) + // or ${env.PORT} (config, only when marked host-sourced) + HostFrom bool // config key whose value still comes from the ambient host/CI env (→ ${env.KEY}) + Service string // which devstack.yaml service the key is rewritten into +} + +type Plan struct { + Source string // path to the .env + Decisions []Decision // emitted in sorted-key order (deterministic) + Dest string // "sops" | "aws-sm" | "infisical" + DestPath string // secrets.enc.yaml | secret-id prefix | infisical path + Provider string // declared provider instance name used in the refs +} +``` +```go +package secrets + +// New optional capability — providers implement it only where a write CLI exists. +type SecretEntry struct{ Path, Key, Value string } +type Pusher interface{ Push(ctx context.Context, entries []SecretEntry) error } +``` + +## CLI surface +``` +devstack secrets ingest [] \ + [--to sops|aws-sm|infisical] [--dest ] \ + [--service ] [--recipient age1...] \ + [--secret ...] [--public ...] [--from-host ...] \ + [--prefixed] [--keep-env] \ + [--dry-run] [--yes] [--force] [--json] +``` +- `` defaults to `./.env`; with no TTY and no `--yes`, ingest runs non-interactively using computed classification + flag overrides. +- `--to` selects the destination backend (default `sops`); `--dest` overrides the file/secret-id/path. +- `--recipient` supplies the age public key; default discovery order: `--recipient` → `.sops.yaml` creation rule → the recipient printed by `secrets keygen` (the local age key under `$DEVSTACK_HOME`, spec 04). +- `--secret`/`--public` force classification; `--from-host` marks a config key as still sourced from the ambient host/CI env so it is emitted as `${env.KEY}` instead of an inline literal. +- `--prefixed` routes secrets to `env.prefixed` (compose key becomes `_`, exactly what `generate/secrets.go SecretRefs` emits) instead of `env.raw` (verbatim key). +- `--dry-run` writes nothing and prints the full plan + would-be diffs (the [spec 14](14-self-update-and-migration.md) contract); `--json` emits the `Plan` as machine-readable and suppresses the TUI. + +## Behavior +1. **Discover & guard.** Resolve the workspace root (walk up for `workspace.yaml`) and the target `devstack.yaml` for `--service`. Refuse early if `.env` is **git-tracked** (`git ls-files --error-unmatch .env` exits 0) with the "already in history; rotate these secrets, this tool can't un-commit them" remediation. Refuse if `--to` names a provider whose builder doesn't implement `Pusher`. If the destination provider instance is not declared in `workspace.yaml`, plan to scaffold it (step 6). +2. **Parse.** `dotenv.Parse` the file into `map[string]string`; sort keys for a deterministic report and stable refs. Reject duplicate keys with `file:line` ([spec 01](01-config-schema.md) error style). +3. **Classify.** For each key, run the policy ladder (override globs → name patterns → value heuristics → default-deny). Produce a `Decision` per key with a human reason. On a TTY without `--yes`, open the `internal/prompt` wizard pre-populated with the computed classes; the user toggles secret/config per key, marks host-sourced config keys, and picks the destination. Non-TTY/`--yes`/`--json` skip the wizard. +4. **Assemble destination payload (no plaintext on disk).** + - **SOPS:** build a YAML map of `{key: value}` for the secret subset (sorted), marshal in-memory with `goccy/go-yaml`, **pipe to `sops --encrypt … /dev/stdin`** via `exec.Cmd.Stdin`; capture ciphertext stdout; `writeIfChanged` it to `secrets.enc.yaml`. Plaintext exists only in process memory and the `sops` child's stdin pipe — never a repo temp file. If a `0600` scratch file is ever unavoidable on a platform, it goes under `$XDG_RUNTIME_DIR` (tmpfs) and is deleted on exit, with the weaker macOS/WSL2 guarantee documented ([spec 04](04-secrets.md)). + - **aws-sm / infisical:** call the provider's `Push` (shelling `aws secretsmanager put-secret-value` / `aws ssm put-parameter --type SecureString` / `infisical secrets set`); values travel via env/stdin, never as logged argv. No flock. +5. **Compute refs.** Each secret key → `secret:///#` (SOPS dot-path; AWS `secret-id#json-key`; Infisical `path/KEY`) — byte-identical to what `ParseRef` accepts and what `generate/compose.go` then emits **valueless**. Each non-secret key → its **inline literal** in the chosen service's `env.raw` (or `${env.}` only when the key was marked host-sourced). +6. **Rewrite committed YAML (in place, AST-preserving).** Replace the touched `environment` values in `devstack.yaml` only; keep header, order, and comments. If the destination provider is missing, add a default `sops` provider entry to `workspace.yaml`. No-clobber: back up each touched file → `.bak.` before writing (skipped under `--dry-run`; required even with `--force`, which only suppresses the prompt). +7. **Verify round-trip.** Re-load config, `Collect` the new refs, and `Resolve` them once per provider ([spec 04](04-secrets.md) batch path). If any new ref fails to resolve to its original value, **abort before deleting `.env`** and roll the YAML back from the backups. +8. **Fence & remove `.env`.** Marker-fence-append `.env` to `.gitignore`; delete `.env` (or, with `--keep-env`, print the `git rm --cached .env && rm .env` command). Print the conversion report: every key, its class, its reason, its emitted ref/literal, and the destination. + +## Worked example +Before — committed `./.env`: +```dotenv +export DB_PASSWORD="s3cr3t-p@ss" +REDIS_URL=redis://shared-redis:6379/0 +APP_ENV=local +STRIPE_SECRET_KEY=sk_live_51Hxxxxxxxxxxxx +PORT=8080 +``` +`devstack secrets ingest --service api` (TTY → wizard, defaults shown): +``` +key class reason +DB_PASSWORD secret name matches *PASSWORD +STRIPE_SECRET_KEY secret name matches *SECRET* + high-entropy value +APP_ENV config no secret signal +PORT config no secret signal +REDIS_URL config no secret signal (DNS alias, no credentials) +→ destination: sops (secrets.enc.yaml, recipient age1ql3z…) +``` +After — `secrets.enc.yaml` (ciphertext, committable) holds `DB_PASSWORD` + `STRIPE_SECRET_KEY`; `devstack.yaml` rewritten (config keys **inlined** as literals, since `.env` is being deleted): +```yaml +apiVersion: devstack/v1 +kind: Project +services: + api: + env: + raw: + DB_PASSWORD: "secret://sops/secrets.enc.yaml#DB_PASSWORD" + STRIPE_SECRET_KEY: "secret://sops/secrets.enc.yaml#STRIPE_SECRET_KEY" + APP_ENV: "local" + PORT: "8080" + REDIS_URL: "redis://shared-redis:6379/0" # ${env.REDIS_URL} only with --from-host REDIS_URL +``` +`.gitignore` gains a marker-fenced `.env`; `./.env` is deleted; the two secret refs are proven to re-resolve to their original values before deletion. + +## Verified constraints / gotchas +- **Don't add a dotenv parser.** `github.com/compose-spec/compose-go/v2/dotenv` is **already vendored** (via `compose-go v2.12.1`), is the maintained fork Docker Compose itself uses, and gives byte-for-byte parity with `docker compose`'s `.env` handling (quoting, multiline, `export`, comments). Use `Parse`; use `ParseWithLookup` only if `${VAR}` interpolation in the source `.env` must be honored. (`joho/godotenv` is the redundant upstream; `hashicorp/go-envparse` is stricter and lacks substitution — both wrong here.) **Determinism:** `Parse` returns an unordered map; emit everything in **sorted-key order** (source order is unrecoverable from the map and not relied upon). +- **SOPS output-type must be yaml/json, NOT dotenv.** dotenv flattens everything to flat string `KEY=VALUE` and can't express nested keys — yet the existing read path (`internal/secrets/sops.go`, and the `postgres.password` test fixture) resolves **dot-path** keys. Encrypt with `--output-type yaml` so nested refs stay expressible and the decrypt side (`sops -d --output-type json`) keeps working unchanged. +- **Keep shelling out to the `sops` binary; do NOT vendor `getsops/sops/v3`.** The repo's `sops.go` deliberately shells `sops` because the Go SDK pulls all cloud-KMS SDKs transitively (binary bloat, larger surface against `CGO_ENABLED=0`). The encrypt half follows suit: `exec.Command("sops", "--encrypt", …)` with plaintext on stdin. +- **`huh` must be the v2 / `charm.land` line, not v1.** Use `charm.land/huh/v2` (v2.0.3), built on `charm.land/bubbletea/v2` + `charm.land/lipgloss/v2`, **byte-aligned with the vendored `charm.land/lipgloss/v2 v2.0.1` + `charm.land/fang/v2 v2.0.1`**. `github.com/charmbracelet/huh` (v1) pulls bubbletea v1/lipgloss v1 and would **double-vendor a conflicting charm stack**. The whole family is pure-Go (x/term, x/ansi, ultraviolet — already vendored), so it stays `CGO_ENABLED=0` across darwin/linux {amd64,arm64} with **no build tags**; keep `make vuln` in CI after adding. +- **The TUI must never be the only path and must no-op on non-TTY.** `huh.Form.Run()` errors when stdin isn't a TTY; gate on `term.IsTerminal` in `internal/prompt` and fall back to the flag/computed-classification path **before** invoking the bubbletea runtime — `--json`/`--quiet`/`CI`/non-interactive never start it ([ARCHITECTURE §7.9](../ARCHITECTURE.md) headline-output contract). +- **No plaintext in any committed file — and a CI leak-test must prove it.** Mirror `generate_test.go:TestSecretCoupling_NoValueInOutput`: after ingesting a fixture `.env`, assert that none of the original secret values appear in `secrets.enc.yaml`, `devstack.yaml`, `workspace.yaml`, `.gitignore`, any `*.bak`, or the report. The only legitimate place a value travels is `sops`'s stdin pipe / the provider `Push` CLI (via stdin/env, never argv). +- **Non-secret keys are inlined, not `${env}`-referenced.** Because ingest deletes `.env`, a `${env.KEY}` for a value that lived in `.env` would resolve to empty at the next `generate`. Default to writing the literal; only emit `${env.KEY}` when the operator marks the key host/CI-sourced (`--from-host`/wizard). +- **Refuse on already-tracked `.env`.** If `.env` is in git history, encrypting a copy doesn't remove the plaintext from the repo's past — the correct remediation is rotate-then-`git rm --cached`/history-rewrite, which this tool won't do silently. Detect with `git ls-files --error-unmatch` and stop with that guidance. +- **Routing to AWS/Infisical needs the NEW `Pusher` capability — it does not exist today.** The `Provider` interface is `Resolve`-only; `--to aws-sm|infisical` must check the built provider implements `Pusher` and otherwise refuse with "provider X is read-only in this build; use `--to sops`." Don't fake a push by writing plaintext anywhere. +- **Ensure the destination provider is declared.** The emitted `secret:///…` ref resolves only if `workspace.yaml` declares a provider instance with that name. Ingest scaffolds a default `sops` provider (and backs `workspace.yaml` up) when absent, or reuses the declared name otherwise. +- **age private key stays uncommitted; only the recipient is shareable.** The `AGE-SECRET-KEY-…` identity remains `0600` under `$DEVSTACK_HOME` (the `secrets keygen` convention, outside the repo tree, so no repo `.gitignore` entry is needed); ingest reads only the `age1…` **recipient** to encrypt. +- **Idempotent write-back (no `make determinism` impact).** `make determinism` asserts byte-identical *generated* artifacts under `.devstack/`; ingest writes **committed** files (`secrets.enc.yaml`, `devstack.yaml`, `workspace.yaml`) and so is outside that harness. SOPS adds a per-file MAC/nonce, so the ciphertext bytes change on every encrypt — therefore ingest's idempotency is enforced by **decrypt-and-compare**: re-running the same ingest decrypts the existing destination, compares it to the freshly assembled plaintext plan (sorted), and **skips re-encryption** when they match, yielding a clean diff. + +## Acceptance criteria +- [ ] `devstack secrets ingest .env` on a TTY opens the `huh` v2 wizard pre-populated with computed classes; `--yes`/`--json`/non-TTY/`CI` run the identical classification with **no** TUI and identical output. +- [ ] Secret keys land in `secrets.enc.yaml` as **ciphertext only**; the CI leak-test finds **no** original secret value in any written or committed file (`secrets.enc.yaml`, `devstack.yaml`, `workspace.yaml`, `.gitignore`, `*.bak`, report). +- [ ] Emitted refs parse via `secrets.ParseRef` and, placed in the rewritten `env.raw`/`env.prefixed`, are emitted **valueless** by `generate/compose.go` (proven by `SecretRefs`) and resolved by the up saga — proven by a round-trip resolve before `.env` deletion. +- [ ] Non-secret keys are written as **inline literals** by default (not `${env.X}`); `--from-host`/the wizard switches an individual key to `${env.X}` for ambient-sourced values. +- [ ] `--dry-run` writes nothing and prints the full plan + unified diffs of `devstack.yaml`/`workspace.yaml`/`secrets.enc.yaml`; `--json` emits the `Plan` and suppresses the TUI. +- [ ] `devstack.yaml` rewrite preserves the `apiVersion` header, key order, and comments; the run backs up each touched committed file before writing and refuses to overwrite without `--force`. +- [ ] Default classification treats an unknown key as **secret**; `--public`/`--secret` globs and per-key wizard toggles override, each with a printed reason. +- [ ] If no matching secrets provider is declared, ingest scaffolds a default `sops` provider in `workspace.yaml` (backed up first) so the round-trip resolve succeeds. +- [ ] `--to aws-sm|infisical` pushes via the provider's `Pusher` and is refused with a clear reason for any `Resolve`-only provider; **no mode takes a flock** (ingest touches neither the ledger nor the shared stack). +- [ ] Ingest refuses when `.env` is git-tracked, and on success marker-fences `.env` in `.gitignore` and deletes it (or prints the removal command under `--keep-env`). +- [ ] `make ci` + `make determinism` stay green (ingest writes committed files, not `.devstack/` artifacts); re-running the same ingest yields a clean diff via decrypt-and-compare; `make vuln` passes with the new `huh`/`bubbletea` v2 deps. + +## Dependencies / consumers +**New deps (all direct, v2 line):** `charm.land/bubbletea/v2` + `charm.land/bubbles/v2` + `charm.land/huh/v2` (v2.0.3) (pinned for charm-stack alignment). **No** new dotenv or SOPS-SDK dep (reuse `compose-go/v2/dotenv`; shell `sops`). Consumes `internal/config` (goccy AST + the `Env{Raw,Prefixed,Import}` / `Secrets{Providers}` model — [spec 01](01-config-schema.md)), `internal/secrets` (the new `Pusher`, `ParseRef`/`IsRef`, `keygen` recipient, the SOPS shell-out — [spec 04](04-secrets.md)), `internal/generate` (`SecretRefs` key-naming + `writeIfChanged` — [spec 02](02-templating-and-generation.md)), `internal/git` (track-check + `GIT_ASKPASS` for a push — [spec 06](06-git.md)), `internal/xdg` (`$XDG_RUNTIME_DIR` scratch + `$DEVSTACK_HOME` key path). Consumed by `internal/cli` (the `secrets ingest` verb under the existing `secrets` subtree — [spec 07](07-cli-and-aliasing.md)). New `internal/prompt` is the reusable non-TTY-degradable TUI wrapper that later interactive flows (e.g. onboarding, [spec 09](09-orchestration-and-onboarding.md)) can adopt. + +## Open questions +- **Q-INGEST-DEST** — default destination for ingested secrets. *Recommendation:* SOPS+age file at the workspace root (offline, no-account, matches spec 04's default provider); AWS/Infisical opt-in via `--to` + the new `Pusher`. **Decision:** SOPS default; `Pusher` ships for AWS-SM + Infisical in the same milestone, SSM behind it. +- **Q-INGEST-CLASS** — default class for an unrecognized key. *Recommendation:* **secret** (default-deny is the safe failure mode; a wrongly-secret key is an easy wizard/`--public` correction, a wrongly-public secret is a leak). **Decision:** default-deny, overridable by glob + wizard. +- **Q-INGEST-CONFIG** — how non-secret keys are emitted. *Recommendation:* inline literal by default (the `.env` source is deleted, so `${env.X}` would resolve to empty); `${env.X}` only when the operator marks the key host/CI-sourced. **Decision:** inline literal default; `--from-host`/wizard opt-in for `${env.X}`. +- **Q-INGEST-VERB** — `secrets ingest` vs a top-level `devstack import-env`. *Recommendation:* hang it on the existing `secrets` subtree (credentials + keygen already live there) and keep top-level `import` for the devdock `project.yaml` path ([spec 14](14-self-update-and-migration.md)). **Decision:** `devstack secrets ingest`. +- **Q-INGEST-MULTI** — where non-secret keys land when `.env` is repo-root but the repo has multiple services. *Recommendation:* require `--service` (or wizard pick) when ambiguous; otherwise default to the single declared service. **Decision:** require an explicit target when >1 service; error with the service list otherwise. \ No newline at end of file diff --git a/docs/specs/25-release-automation.md b/docs/specs/25-release-automation.md new file mode 100644 index 0000000..06d197e --- /dev/null +++ b/docs/specs/25-release-automation.md @@ -0,0 +1,148 @@ +# Spec 25 — Release automation, conventional-commit versioning & the v0.2.0 cut + +**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 + +## Purpose +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` tag → grouped changelog → goreleaser release**, wired so the existing tag-triggered release engine and `self update`/notifier ([spec 14](14-self-update-and-migration.md)) 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. + +## Decisions +- **Version computation = `caarlos0/svu` (pure-Go, by the goreleaser author), always `--v0`.** CI runs `svu next --v0` to derive the next tag from conventional-commit history; the existing `release.yml` (`on: push: tags: ["v*"]`) stays the release engine. 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 --v0` makes 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 has `MAJOR != 0`. `.svu.yaml` sets `v0: true` so the flag can never be forgotten. +- **Two-workflow split, App-token tag push.** A new `tag.yml` (`on: push: main` + `workflow_dispatch`) computes the version and pushes the `v*` tag using a **GitHub App / fine-grained PAT** secret (`RELEASE_TOKEN`); the existing `release.yml` fires on the tag and runs goreleaser unchanged. Reason: a tag pushed with the default `GITHUB_TOKEN` does **not** re-trigger workflows (Actions suppresses events from `GITHUB_TOKEN`), so a plain bot push would never fire `release.yml`. The App token is owner-gated (least-privilege, no expiry) and **its absence is the kill-switch**: `tag.yml` computes-and-logs but **does not push** when `RELEASE_TOKEN` is unset, so a private / pre-public repo never auto-releases (honors the owner-only release-flip, [PROGRESS](../PROGRESS.md) decision #4). The secret is exposed at **job level** so the push step's `if:` can actually read it (a step-level `env:` is invisible to that same step's `if:`). +- **Single changelog source of truth = goreleaser, grouped by conventional type.** Upgrade `.goreleaser.yaml` `changelog` to `groups:` (Features/Fixes/Performance/…) with `filters.exclude` for `chore|docs|test|ci|build|style`. Grouping/filtering apply with `use: github` (and `use: git`) but are **ignored** under `use: github-native`, so the config must stay on `github`. `@semantic-release/release-notes-generator` is **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.yaml` ldflags from `version.Version={{.Version}}` to `version.Version=v{{ .Version }}` so the stamped `internal/version.Version` is a clean, **v-prefixed** semver that `x/mod/semver` accepts. The archive `name_template` stays on the **v-stripped** `{{ .Version }}` to match `assetName`'s `TrimPrefix` ([spec 14](14-self-update-and-migration.md), `update.go:78`). A CI step asserts the built binary's `version --short` output is `semver.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_request` types `opened|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 new `internal/release` shells to the `svu` binary behind an interface (mockable, like `internal/git`/`internal/docker`); `internal/cli` adds `release` which previews next version + grouped changelog and optionally creates/pushes the tag. Interactive confirm via `charm.land/huh/v2` behind `internal/prompt`, with a non-TTY/`--yes`/`--json`/`--dry-run` fallback 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|prerelease` key (default `stable`) under `apiVersion: devstack/v1` so a maintainer can opt into `-rc`/`-beta` tags. 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 future `v0.3.0-beta.1` safe rather than a surprise to stable users. + +```yaml +# .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 — 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 +``` + +```diff +# .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 '] +``` + +```yaml +# .github/workflows/tag.yml — NEW. Computes + pushes the v* tag; release.yml then fires. +name: Tag +on: + push: { branches: [main], paths-ignore: ["**/*.md", "docs/**", "LICENSE", "NOTICE"] } + workflow_dispatch: {} +permissions: { contents: write } +concurrency: { group: tag-main, cancel-in-progress: false } # never race two taggers +jobs: + tag: + runs-on: ubuntu-latest + env: + RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }} # JOB-level: required so the push step's `if:` can read it + steps: + - uses: actions/checkout@v4 + with: { fetch-depth: 0 } # full history + tags for svu + - uses: actions/setup-go@v5 + with: { go-version: "1.25", check-latest: true } + - name: install svu (pinned) + # Resolve the real latest v3.x once and pin the exact tag here; the v3 + # module path is fixed, the patch must be confirmed before merge. + run: go install github.com/caarlos0/svu/v3@v3.2.3 + - name: compute next version + id: svu + 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! must NEVER yield v1.0.0) + run: | + case "${{ steps.svu.outputs.next }}" in + v0.*) : ;; + *) echo "::error::refusing non-0.x tag ${{ steps.svu.outputs.next }} while in BETA"; exit 1 ;; + esac + - name: push tag (only when enabled + a real bump) + if: ${{ steps.svu.outputs.next != steps.svu.outputs.current && env.RELEASE_TOKEN != '' }} + run: | + git config user.name "devstack-release[bot]" + git config user.email "release@devstack.local" + git remote set-url origin "https://x-access-token:${RELEASE_TOKEN}@github.com/${{ github.repository }}.git" + git tag "${{ steps.svu.outputs.next }}" + git push origin "${{ steps.svu.outputs.next }}" # fires release.yml (App token ≠ GITHUB_TOKEN) +``` + +## Behavior +**Automated release pipeline (CI):** +1. PR merges to `main` (squash; PR title is the commit, already conventional-lint-checked on the PR). +2. `tag.yml` checks out full history, installs the pinned `svu`, runs `svu next --v0` → e.g. `v0.2.0`. +3. **0.x guard:** if the computed tag is not `v0.*`, fail the job (a `feat!`/`BREAKING CHANGE` during BETA must bump the minor, not major). +4. If the computed tag differs from the latest existing tag **and** `RELEASE_TOKEN` is set, push the tag with the App token. Otherwise log "no release due" / "tagging disabled" and exit 0 (no push — the pre-public kill-switch). +5. The pushed `v*` tag triggers the unchanged `release.yml` → goreleaser `release --clean`: builds the 4 CGO-free targets, stamps `version.Version=v0.2.0`, emits the **grouped** changelog, archives (v-stripped names) + `checksums.txt` + `.deb`/`.rpm`, creates the GitHub Release. +6. A user on v0.1.0 runs any command → the notifier's `LatestTag` resolves `v0.2.0` (v-prefixed `tag_name`), `semver.Compare("v0.2.0","v0.1.0")>0` → footer shown. `devstack self update` downloads `devstack_0.2.0__.tar.gz` (v-stripped), verifies `checksums.txt`, atomically replaces — all unchanged from [spec 14](14-self-update-and-migration.md), now actually reachable because the stamped version is semver-valid. + +**`devstack release` (maintainer, interactive — local convenience over the same svu):** +1. Resolve current tag + `svu next --v0`; render a grouped changelog preview (commits since the last tag, bucketed by conventional type). +2. **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`. +3. **Non-TTY / `--yes` / `--json` / CI:** never start the TUI. `--json` prints `{current,next,bump,changelog,wouldPush}` and exits; `--dry-run` prints the plan and writes nothing; `--yes` tags non-interactively; `--no-push` tags locally only. +4. `release --check` prints the next version and exits **0 if a release is due**, **non-zero if not** (scriptable gate, reads as `if devstack release --check; then ...`). **Note the deliberate inversion:** unlike the drift-style `generate --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. `--quiet` suppresses 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 adding the `RELEASE_TOKEN` secret (or runs `devstack release --yes` / `git tag v0.2.0 && git push origin v0.2.0` manually). No `0.0.0` seeding hazard exists here — the repo already has a non-zero `v0.1.0` baseline. + +## Verified constraints / gotchas +- **The v-stripped ldflags is a silent muter, not cosmetic.** goreleaser's `{{ .Version }}` is the tag *without* the leading `v` (`0.2.0`), but `x/mod/semver` requires the `v` and treats malformed input as lowest. Confirmed against the code: `internal/selfupdate/selfupdate.go` `IsDevBuild(v)` returns true when `!semver.IsValid(v)`, and `notify.go`/`update.go` short-circuit on `IsDevBuild`. A goreleaser binary stamped `0.2.0` → `IsDevBuild=true` → **the notifier goes silent and `self update` never sees an update**. Fix the ldflags to `v{{ .Version }}`; keep the archive `name_template` v-stripped (it must match `assetName = devstack___.tar.gz`, `update.go:78`). These two opposite conventions are both load-bearing — do not "unify" them. +- **A bot tag pushed with `GITHUB_TOKEN` will not fire `release.yml`.** GitHub deliberately suppresses workflow events from the default token to prevent recursion. Use a GitHub App / fine-grained PAT (`RELEASE_TOKEN`) for the tag push, or collapse tag-compute + goreleaser into one job. The two-workflow + App-token split keeps `release.yml` untouched and owner-gated. **`release.yml` itself still uses `GITHUB_TOKEN`** for goreleaser (it only creates a release; it does not need to re-trigger anything), so it is unchanged. +- **A step-level `env:` is invisible to that step's own `if:`.** The `env` context in an `if:` expression resolves only workflow- and job-level env. Therefore `RELEASE_TOKEN` must be declared at **job level** (as in the `tag.yml` above), not on the push step, or the kill-switch `if: env.RELEASE_TOKEN != ''` always reads empty and the tag never pushes. +- **Only ever push `v` tags.** A non-semver tag (a channel/range/build-meta suffix, or a milestone label like `m2-done`) breaks the `release-dryrun` job's `goreleaser` `git describe` for *every* PR (ci.yml job `release-dryrun` runs `release --snapshot --clean`; [PROGRESS](../PROGRESS.md) decision #1). svu's default `tagFormat` `v${version}` satisfies this; `.svu.yaml` must never add a suffix. +- **`--v0` is not the default — forget it and BETA breaks.** Plain `svu next` bumps a BREAKING change to 1.0.0. Always pass `--v0` (and set `v0: true` in `.svu.yaml`) **and** keep the CI `v0.*` guard as defense-in-depth. (Note the release-please trap if you ever switch tools: with a `0.0.0` manifest its `bump-*-pre-major` flags are ignored and it recommends 1.0.0 — you must seed at `0.1.0`. svu reading the existing `v0.1.0` tag avoids this entirely.) +- **`x/mod/semver` orders 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`). Staying `MAJOR=0` keeps every notifier/self-update comparison valid; tag any beta as `vX.Y.Z-beta.N` so 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 transitive `charm.land/bubbletea/v2` and `charm.land/bubbles/v2`, byte-aligned with the already-vendored `charm.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-run `make vuln` after 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 behind `internal/prompt` with a non-TTY/`--json`/`--quiet`/`CI` fallback to flags so the headline-output contract ([ARCHITECTURE §7.9](../ARCHITECTURE.md)) 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 `fix` typo'd as `bugfix` → no bump). Lint PR titles (pure shell regex, no Node action) and require "PR title" as the squash message in repo settings. +- **`devstack release` mutates 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 (like `devstack import`, [spec 14](14-self-update-and-migration.md)) it takes **no** `internal/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 by `release`/`version` (the no-plaintext rule, [spec 04](04-secrets.md)). +- **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-vendored `github.com/compose-spec/compose-go/v2/dotenv` — not `joho/godotenv`/`hashicorp/go-envparse`. + +## Acceptance criteria +- [ ] Merging a `feat:` PR to `main` makes `tag.yml` compute `v0.2.0` via `svu next --v0`; with `RELEASE_TOKEN` set it pushes the tag, which triggers `release.yml` → a GitHub Release with grouped (Features/Fixes/…) notes. +- [ ] A `feat!:` / `BREAKING CHANGE` commit while in BETA computes a **minor** bump (e.g. `v0.2.0`), never `v1.0.0`; the CI `v0.*` guard fails the run if any non-0.x tag would be produced. +- [ ] When `RELEASE_TOKEN` is unset, `tag.yml` logs the computed version and exits 0 **without pushing** (pre-public kill-switch); when the computed tag equals the latest existing tag it also no-ops. The push-step `if:` resolves `RELEASE_TOKEN` from job-level env (a deliberate test: an empty secret must skip the push). +- [ ] A goreleaser-built binary reports `version --short` = a v-prefixed string that `semver.IsValid` accepts (verified on both a real tag and the `--snapshot` build, whose `v0.1.1-dev-` is still valid semver); a CI step asserts it. The release archive filename stays v-stripped (`devstack_0.2.0__.tar.gz`). +- [ ] After v0.2.0 is published, a v0.1.0 user sees the notifier footer and `self update` installs v0.2.0 (checksum-verified) with no other change to [spec 14](14-self-update-and-migration.md) code — i.e. the previously-muted path now fires. +- [ ] A PR whose title is not `type(scope)?: subject` fails the PR-title lint; a conventional title passes. No Node toolchain is added to CI. +- [ ] `devstack release --json` prints `{current,next,bump,changelog,wouldPush}` and writes nothing; `--dry-run` prints the plan; `--yes` tags non-interactively; on a non-TTY / `CI`, `release` never starts the bubbletea runtime. +- [ ] `devstack release --check` exits 0 when a release is due and non-zero otherwise (gate semantics, documented as inverted from `generate --check`). +- [ ] `release-dryrun` (`goreleaser release --snapshot --clean`), `make determinism`, and the golden tests stay green after the ldflags/changelog/`.svu.yaml` changes; only `v*` tags exist in the repo. +- [ ] The notifier suppresses `-rc`/`-beta` tags unless `update.channel: prerelease` (or the running build is itself a pre-release); a `v0.3.0-beta.1` tag does not nag a stable user. The `update.channel` key loads forward-tolerantly under `apiVersion: devstack/v1` ([spec 01](01-config-schema.md)/[spec 14](14-self-update-and-migration.md)). +- [ ] Adding `charm.land/huh/v2` + transitive bubbletea/bubbles v2 keeps `CGO_ENABLED=0 go build ./...`, the 4-target cross-compile, and `make vuln` green; no charm v1 packages enter `go.mod`. + +## Dependencies / consumers +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](06-git.md), 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](14-self-update-and-migration.md)/[spec 01](01-config-schema.md)). 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](14-self-update-and-migration.md)) is the direct beneficiary — the v-prefix fix and the `update.channel` knob make its notifier + `self update` actually reachable. CI consumers: `tag.yml` (new), `release.yml` (unchanged engine), `ci.yml` (new PR-title lint + the `version --short` semver assertion; 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: `charm.land/huh/v2` (latest v2.0.x) + transitive `charm.land/bubbletea/v2` / `charm.land/bubbles/v2` (v2 line). **Thin vs full:** the thin slice (`.svu.yaml` + `tag.yml` + ldflags/changelog fix + 0.x guard + PR-title lint) is ~0.75w and is all that's needed to cut v0.2.0; the `devstack release` huh wizard + `internal/release`/`internal/prompt` + `update.channel` filter add ~0.75w. + +## Open questions +- **Orchestration shape — two-workflow (App-token) vs single-job.** Two workflows keep `release.yml` untouched and the owner kill-switch clean, at the cost of one App-token secret. A single job (svu + goreleaser together) needs no extra token but folds tagging into the release run. *Recommendation: two-workflow + `RELEASE_TOKEN`.* **Decision:** two-workflow split; revisit only if App-token management proves heavier than the recursion-avoidance it buys. +- **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.Scanner` y/n prompt behind the same `internal/prompt` interface 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:** keep `internal/prompt` as the seam either way; default to stdlib, leave huh as a drop-in upgrade. +- **Committed `CHANGELOG.md` vs release-notes-only.** goreleaser grouped notes live on the GitHub Release; a committed `CHANGELOG.md` would need git-cliff/semantic-release (a second generator) and a bot commit to `main`. *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 generated `CHANGELOG.md` artifact (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 release` source of truth: shell-to-`svu` vs reimplement bump in pure Go.** Shelling matches the repo's "wrap external tool behind an `internal/` interface" rule and guarantees lockstep with CI; reimplementing avoids a runtime dep but risks divergence. *Recommendation: shell to `svu` behind `internal/release` with a "svu not found" remediation (maintainer command).* **Decision:** wrap `svu`; a CI test asserts `devstack release --check` agrees with `svu 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 the `update.channel` knob now (cheap) so a future beta is safe.* **Decision:** implement additively; default `update.channel: stable`, spec 14 owns the behavior. \ No newline at end of file From 8bbd76cb5b2dc4ac49abbbe860ba9145a7b4371f Mon Sep 17 00:00:00 2001 From: Gustavo Bertoi Date: Tue, 30 Jun 2026 14:49:41 -0300 Subject: [PATCH 2/3] feat(release): conventional-commit versioning (svu) + fix self-update v-prefix bug Implements spec 25's thin slice (the v0.2.0 gate): - .svu.yaml (v0:true): keeps BETA on the 0.x line (a BREAKING change bumps the MINOR, never 1.0.0). - .github/workflows/tag.yml: push-to-main -> `svu next --v0` (pinned v3.4.1) -> push a v* tag, gated on an owner-provisioned RELEASE_TOKEN (absent = kill switch) with a CI v0.* guard, which fires the unchanged release.yml/goreleaser. - .github/workflows/pr-title.yml: pure-shell conventional-commit PR-title lint (squash-merge makes the PR title the commit svu reads). - .goreleaser.yaml: grouped changelog (Features/Bug fixes/Performance) under `use: github`, and the load-bearing ldflags fix Version=v{{.Version}}. The ldflags fix is a real regression, not cosmetic: goreleaser's {{.Version}} is v-stripped ("0.2.0"), which golang.org/x/mod/semver rejects, so internal/ selfupdate.IsDevBuild() returns true for a real release -> the update notifier and `self update` silently treat it as a dev build and never fire. Verified against the real code: IsDevBuild("0.2.0")=true vs IsDevBuild("v0.2.0")=false; goreleaser snapshot now stamps v0.1.1-dev-. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/pr-title.yml | 42 ++++++++++++++++++ .github/workflows/tag.yml | 78 ++++++++++++++++++++++++++++++++++ .goreleaser.yaml | 20 ++++++++- .svu.yaml | 11 +++++ 4 files changed, 150 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/pr-title.yml create mode 100644 .github/workflows/tag.yml create mode 100644 .svu.yaml diff --git a/.github/workflows/pr-title.yml b/.github/workflows/pr-title.yml new file mode 100644 index 0000000..7d283d3 --- /dev/null +++ b/.github/workflows/pr-title.yml @@ -0,0 +1,42 @@ +name: PR title + +# Conventional-commit lint for PR titles (spec 25). The repo squash-merges, so the +# PR title becomes the commit subject that svu (tag.yml) reads to compute the next +# version — an unconventional title silently corrupts version computation. Pure +# shell, no Node toolchain (consistent with the rest of CI). +# +# Repo setting to pair with this: squash-merge only, with "Default to PR title" for +# the squash commit message. + +on: + pull_request: + types: [opened, edited, synchronize, reopened] + +permissions: + contents: read + +concurrency: + group: pr-title-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - name: check conventional-commit subject + # Pass the title via env (never inline-interpolated into the script) so a + # crafted PR title cannot inject shell. + env: + PR_TITLE: ${{ github.event.pull_request.title }} + run: | + set -eu + pattern='^(feat|fix|docs|chore|ci|refactor|test|perf|build|style|revert)(\([a-z0-9._-]+\))?!?: .+' + if printf '%s' "$PR_TITLE" | grep -qiE "$pattern"; then + echo "ok: \"$PR_TITLE\"" + else + echo "::error title=Non-conventional PR title::\"$PR_TITLE\" must be type(scope)?: subject" + echo "Allowed types: feat fix docs chore ci refactor test perf build style revert" + echo "Examples: 'feat(init): add shared-service picker' | 'fix(release): stamp v-prefixed version'" + echo "Note: only feat (minor) / fix|perf (patch) / a '!' or BREAKING CHANGE bump the 0.x version." + exit 1 + fi diff --git a/.github/workflows/tag.yml b/.github/workflows/tag.yml new file mode 100644 index 0000000..a1f4599 --- /dev/null +++ b/.github/workflows/tag.yml @@ -0,0 +1,78 @@ +name: Tag + +# Conventional-commit → automatic v* semver tag (spec 25). On every push to main +# this computes the next version with svu (pinned) and, IF enabled, pushes a v* +# tag — which fires the unchanged release.yml → goreleaser. The project is in +# BETA: svu runs with --v0 (KeepV0) so a BREAKING change bumps the MINOR, never +# 1.0.0, and a guard step refuses any non-0.x tag as defense-in-depth. +# +# Kill-switch: the tag is pushed only when the owner-provisioned RELEASE_TOKEN +# secret is present. Without it the job computes + logs the version and exits 0 +# WITHOUT pushing, so a private / pre-public repo never auto-releases. + +on: + push: + branches: [main] + paths-ignore: ["**/*.md", "docs/**", "LICENSE", "NOTICE"] + workflow_dispatch: {} + +# Least privilege: the tag PUSH authenticates via RELEASE_TOKEN's remote URL, not +# GITHUB_TOKEN — so this workflow only needs read. (A tag pushed with the default +# GITHUB_TOKEN would NOT re-trigger release.yml; GitHub suppresses events from it.) +permissions: + contents: read + +# Never race two taggers; do not cancel an in-flight tag push. +concurrency: + group: tag-main + cancel-in-progress: false + +jobs: + tag: + runs-on: ubuntu-latest + # JOB-level so the push step's `if:` can read it. A step-level env: is invisible + # to that same step's own if: expression — putting it there silently disables + # the push. + env: + RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 # full history + tags for svu (checkout uses GITHUB_TOKEN; push uses RELEASE_TOKEN) + - uses: actions/setup-go@v5 + with: + go-version: "1.25" + check-latest: true + - name: install svu (pinned) + run: go install github.com/caarlos0/svu/v3@v3.4.1 + - name: compute next version + id: svu + 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" + echo "svu: $CUR -> $NEXT" + - name: 0.x guard (a stray feat!/BREAKING must NEVER yield v1.0.0 while in BETA) + run: | + case "${{ steps.svu.outputs.next }}" in + v0.*) echo "ok: ${{ steps.svu.outputs.next }} is on the 0.x beta line" ;; + *) echo "::error::refusing non-0.x tag ${{ steps.svu.outputs.next }} while in BETA"; exit 1 ;; + esac + - name: push tag (only when enabled + a real bump) + if: ${{ steps.svu.outputs.next != steps.svu.outputs.current && env.RELEASE_TOKEN != '' }} + run: | + git config user.name "devstack-release[bot]" + git config user.email "release@devstack.local" + git remote set-url origin "https://x-access-token:${RELEASE_TOKEN}@github.com/${{ github.repository }}.git" + git tag "${{ steps.svu.outputs.next }}" + git push origin "${{ steps.svu.outputs.next }}" # fires release.yml (RELEASE_TOKEN != GITHUB_TOKEN) + echo "pushed ${{ steps.svu.outputs.next }}" + - name: report (no push) + if: ${{ !(steps.svu.outputs.next != steps.svu.outputs.current && env.RELEASE_TOKEN != '') }} + run: | + if [ "${{ steps.svu.outputs.next }}" = "${{ steps.svu.outputs.current }}" ]; then + echo "no release due: no version-bumping commits since ${{ steps.svu.outputs.current }}" + else + echo "tagging disabled: RELEASE_TOKEN is unset (pre-public kill-switch); would have pushed ${{ steps.svu.outputs.next }}" + fi diff --git a/.goreleaser.yaml b/.goreleaser.yaml index fcbd882..56417ad 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -20,7 +20,15 @@ builds: - -trimpath ldflags: - -s -w - - -X github.com/open-source-cloud/devstack/internal/version.Version={{.Version}} + # Stamp a `v`-PREFIXED semver. goreleaser's {{.Version}} is the tag with the + # leading `v` STRIPPED ("0.2.0"); golang.org/x/mod/semver requires the `v` + # and treats malformed input as lowest, so a stripped string makes + # internal/selfupdate's IsDevBuild() return true → the update notifier + + # `self update` silently treat a real release as a dev build and never fire + # (spec 25). The archive name_template below stays v-STRIPPED on purpose, to + # match assetName's TrimPrefix in internal/selfupdate (spec 14) — the two + # opposite conventions are both load-bearing; do not "unify" them. + - -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}} @@ -63,5 +71,15 @@ snapshot: version_template: "{{ incpatch .Version }}-dev-{{ .ShortCommit }}" changelog: + # MUST stay `github` (or `git`): `groups`/`filters` below are IGNORED under + # `github-native`. goreleaser is the SINGLE source of release notes (no second + # generator) — it buckets the conventional-commit history pushed by tag.yml. use: github 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 '] diff --git a/.svu.yaml b/.svu.yaml new file mode 100644 index 0000000..f4f94a1 --- /dev/null +++ b/.svu.yaml @@ -0,0 +1,11 @@ +# svu (caarlos0/svu) config — drives conventional-commit → semver in CI (spec 25). +# +# v0 is NON-NEGOTIABLE while the project is in BETA: KeepV0 makes a BREAKING +# change bump the MINOR (0.1.x → 0.2.0), never 1.0.0. Reaching 1.0.0 is a +# deliberate, manual owner decision (drop this flag, cut v1.0.0 by hand) — it is +# never produced by automation. A CI `v0.*` guard in tag.yml backs this up. +# +# Keep this file MINIMAL. svu's DEFAULT tag format is already `v`; do NOT +# add a tag prefix/suffix or build-meta here — a non-semver tag breaks the +# release-dryrun job's `goreleaser` for every PR (PROGRESS decision #1). +v0: true From 15eacb874bbc0f82fc5f8c308da9993d4b2a9eac Mon Sep 17 00:00:00 2001 From: Gustavo Bertoi Date: Tue, 30 Jun 2026 16:09:02 -0300 Subject: [PATCH 3/3] refactor(release): single-workflow release, built-in token (no PAT) Per owner decision, drop the two-workflow + RELEASE_TOKEN (PAT/App token) split in favor of ONE release.yml that computes -> tags -> releases in a single job using the built-in GITHUB_TOKEN. Rationale: contents:write lets a workflow push a tag, but a tag pushed with GITHUB_TOKEN does NOT re-trigger another workflow (GitHub's recursion guard) -- so the only way to avoid a separate token is to run tag-compute and goreleaser in the same job, never depending on a re-trigger. - Remove .github/workflows/tag.yml. - Rewrite .github/workflows/release.yml: triggers on push:main (svu compute + 0.x guard + tag, gated on the RELEASE_ENABLED repo *variable*) AND on push of a v* tag (a human hand-cut release) AND workflow_dispatch; goreleaser runs once, guarded by github.ref so the two paths never double-release. - Kill-switch is now the RELEASE_ENABLED repo variable (no secret to rotate); default unset = compute + log, never release. - Update spec 25 / FEATURES #17 / ROADMAP M8.0 to the no-token design. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/pr-title.yml | 2 +- .github/workflows/release.yml | 78 +++++++++++++++++++++++++-- .github/workflows/tag.yml | 78 --------------------------- .goreleaser.yaml | 2 +- .svu.yaml | 2 +- docs/FEATURES.md | 2 +- docs/ROADMAP.md | 2 +- docs/specs/25-release-automation.md | 84 +++++++++++++++++------------ 8 files changed, 129 insertions(+), 121 deletions(-) delete mode 100644 .github/workflows/tag.yml diff --git a/.github/workflows/pr-title.yml b/.github/workflows/pr-title.yml index 7d283d3..6f10460 100644 --- a/.github/workflows/pr-title.yml +++ b/.github/workflows/pr-title.yml @@ -1,7 +1,7 @@ name: PR title # Conventional-commit lint for PR titles (spec 25). The repo squash-merges, so the -# PR title becomes the commit subject that svu (tag.yml) reads to compute the next +# PR title becomes the commit subject that svu (release.yml) reads to compute the next # version — an unconventional title silently corrupts version computation. Pure # shell, no Node toolchain (consistent with the rest of CI). # diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9ef2969..bb41878 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,24 +1,96 @@ name: Release +# Single-workflow release (spec 25), built-in GITHUB_TOKEN only — no PAT/App token. +# +# Two entry points, one job: +# • push to main → svu computes the next 0.x version from conventional +# commits and, IF enabled, tags + releases in THIS job. +# • push of a v* tag → a human cut a tag by hand → just run goreleaser. +# +# Tag-compute and goreleaser run together ON PURPOSE: GitHub suppresses workflow +# events triggered by GITHUB_TOKEN, so a tag pushed here does NOT re-trigger this +# workflow (no double release) — which is exactly why a split tag→release setup +# would have needed a separate token. We avoid the token by never depending on +# that re-trigger. +# +# Kill-switch (automated path only): the repository variable RELEASE_ENABLED. +# Unset/anything-but-"true" (the default) ⇒ compute + log, never release. Enable +# once with: gh variable set RELEASE_ENABLED --body true +# A manual `git tag vX.Y.Z && git push` always releases (human pushes are not +# suppressed and are not gated — explicit intent). + on: push: + branches: [main] tags: ["v*"] + paths-ignore: ["**/*.md", "docs/**", "LICENSE", "NOTICE"] + workflow_dispatch: {} permissions: - contents: write # create the GitHub release + upload artifacts + contents: write # tag push + goreleaser GitHub Release, both via GITHUB_TOKEN + +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false jobs: - goreleaser: + release: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 with: - fetch-depth: 0 # goreleaser needs full history + tags + 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 / workflow_dispatch): compute + tag --- + - name: install svu (pinned) + if: ${{ !startsWith(github.ref, 'refs/tags/') }} + run: go install github.com/caarlos0/svu/v3@v3.4.1 + - 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" + echo "svu: $CUR -> $NEXT" + - 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: ${{ steps.svu.outputs.next }} is on the 0.x beta line" ;; + *) echo "::error::refusing non-0.x tag ${{ steps.svu.outputs.next }} while in BETA"; exit 1 ;; + esac + - name: gate + tag (only when 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 "releases disabled: set repo variable RELEASE_ENABLED=true to enable (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: no version-bumping commits since ${{ steps.svu.outputs.current }}" + exit 0 + fi + git config user.name "devstack-release[bot]" + git config user.email "release@devstack.local" + git tag "${{ steps.svu.outputs.next }}" + # GITHUB_TOKEN push: does NOT re-trigger this workflow's tag filter (so no + # double release); we run goreleaser below in this same job. + git push origin "${{ steps.svu.outputs.next }}" + echo "go=true" >> "$GITHUB_OUTPUT" + echo "tagged + pushed ${{ steps.svu.outputs.next }}" + + # --- 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 diff --git a/.github/workflows/tag.yml b/.github/workflows/tag.yml deleted file mode 100644 index a1f4599..0000000 --- a/.github/workflows/tag.yml +++ /dev/null @@ -1,78 +0,0 @@ -name: Tag - -# Conventional-commit → automatic v* semver tag (spec 25). On every push to main -# this computes the next version with svu (pinned) and, IF enabled, pushes a v* -# tag — which fires the unchanged release.yml → goreleaser. The project is in -# BETA: svu runs with --v0 (KeepV0) so a BREAKING change bumps the MINOR, never -# 1.0.0, and a guard step refuses any non-0.x tag as defense-in-depth. -# -# Kill-switch: the tag is pushed only when the owner-provisioned RELEASE_TOKEN -# secret is present. Without it the job computes + logs the version and exits 0 -# WITHOUT pushing, so a private / pre-public repo never auto-releases. - -on: - push: - branches: [main] - paths-ignore: ["**/*.md", "docs/**", "LICENSE", "NOTICE"] - workflow_dispatch: {} - -# Least privilege: the tag PUSH authenticates via RELEASE_TOKEN's remote URL, not -# GITHUB_TOKEN — so this workflow only needs read. (A tag pushed with the default -# GITHUB_TOKEN would NOT re-trigger release.yml; GitHub suppresses events from it.) -permissions: - contents: read - -# Never race two taggers; do not cancel an in-flight tag push. -concurrency: - group: tag-main - cancel-in-progress: false - -jobs: - tag: - runs-on: ubuntu-latest - # JOB-level so the push step's `if:` can read it. A step-level env: is invisible - # to that same step's own if: expression — putting it there silently disables - # the push. - env: - RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }} - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 # full history + tags for svu (checkout uses GITHUB_TOKEN; push uses RELEASE_TOKEN) - - uses: actions/setup-go@v5 - with: - go-version: "1.25" - check-latest: true - - name: install svu (pinned) - run: go install github.com/caarlos0/svu/v3@v3.4.1 - - name: compute next version - id: svu - 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" - echo "svu: $CUR -> $NEXT" - - name: 0.x guard (a stray feat!/BREAKING must NEVER yield v1.0.0 while in BETA) - run: | - case "${{ steps.svu.outputs.next }}" in - v0.*) echo "ok: ${{ steps.svu.outputs.next }} is on the 0.x beta line" ;; - *) echo "::error::refusing non-0.x tag ${{ steps.svu.outputs.next }} while in BETA"; exit 1 ;; - esac - - name: push tag (only when enabled + a real bump) - if: ${{ steps.svu.outputs.next != steps.svu.outputs.current && env.RELEASE_TOKEN != '' }} - run: | - git config user.name "devstack-release[bot]" - git config user.email "release@devstack.local" - git remote set-url origin "https://x-access-token:${RELEASE_TOKEN}@github.com/${{ github.repository }}.git" - git tag "${{ steps.svu.outputs.next }}" - git push origin "${{ steps.svu.outputs.next }}" # fires release.yml (RELEASE_TOKEN != GITHUB_TOKEN) - echo "pushed ${{ steps.svu.outputs.next }}" - - name: report (no push) - if: ${{ !(steps.svu.outputs.next != steps.svu.outputs.current && env.RELEASE_TOKEN != '') }} - run: | - if [ "${{ steps.svu.outputs.next }}" = "${{ steps.svu.outputs.current }}" ]; then - echo "no release due: no version-bumping commits since ${{ steps.svu.outputs.current }}" - else - echo "tagging disabled: RELEASE_TOKEN is unset (pre-public kill-switch); would have pushed ${{ steps.svu.outputs.next }}" - fi diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 56417ad..ac77393 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -73,7 +73,7 @@ snapshot: changelog: # MUST stay `github` (or `git`): `groups`/`filters` below are IGNORED under # `github-native`. goreleaser is the SINGLE source of release notes (no second - # generator) — it buckets the conventional-commit history pushed by tag.yml. + # generator) — it buckets the conventional-commit history at release time. use: github sort: asc groups: diff --git a/.svu.yaml b/.svu.yaml index f4f94a1..d01d8b6 100644 --- a/.svu.yaml +++ b/.svu.yaml @@ -3,7 +3,7 @@ # v0 is NON-NEGOTIABLE while the project is in BETA: KeepV0 makes a BREAKING # change bump the MINOR (0.1.x → 0.2.0), never 1.0.0. Reaching 1.0.0 is a # deliberate, manual owner decision (drop this flag, cut v1.0.0 by hand) — it is -# never produced by automation. A CI `v0.*` guard in tag.yml backs this up. +# never produced by automation. A CI `v0.*` guard in release.yml backs this up. # # Keep this file MINIMAL. svu's DEFAULT tag format is already `v`; do NOT # add a tag prefix/suffix or build-meta here — a non-semver tag breaks the diff --git a/docs/FEATURES.md b/docs/FEATURES.md index 12ea922..bf398b5 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -75,7 +75,7 @@ Guided `devstack init`: a Bubble Tea v2 TUI (left engine-picker + right live `wo `devstack secrets ingest [<.env>]`: converts a committed dotenv into SOPS+age `secret://` refs + **inlined** config-var literals (not `${env.KEY}` — that resolves empty once the `.env` is deleted; `--from-host` opts a key back to ambient host/CI sourcing) and rewrites the target `devstack.yaml`. `--to sops|aws-sm|infisical`; scaffolds a default sops provider when none is declared. Adds the secrets **Pusher** write capability (the existing providers were Resolve-only); parses via the already-vendored `compose-go/v2/dotenv`; idempotency is decrypt-and-compare. No plaintext on disk (CI leak-test). *The migration on-ramp off committed `.env` files.* **17. Release automation + 0.x conventional-commit versioning · 0.75w thin (+0.75w wizard) — the v0.2.0 gate.** ([spec 25](specs/25-release-automation.md)) -Conventional commits on `main` → `svu next --v0` → a `v*` semver tag (owner-gated `RELEASE_TOKEN`; its absence is the kill-switch) → the existing tag-triggered goreleaser. Fixes the load-bearing **ldflags v-prefix bug** (`{{.Version}}` stamps `0.1.0`, which `x/mod/semver` rejects) that currently makes the shipped spec-14 update-notifier + `self update` treat a released binary as a dev build and never offer updates. Adds a grouped goreleaser changelog, a PR-title conventional-commit lint, and a CI `v0.*` guard (stay 0.x: BREAKING → minor, never 1.0.0). Optional `devstack release` maintainer wizard. *Everything else in this lane ships through this pipeline — build it first.* +Conventional commits on `main` → `svu next --v0` → tag + goreleaser **in one workflow** using the built-in `GITHUB_TOKEN` (no PAT/App token), gated by an owner-set `RELEASE_ENABLED` repo variable (default off = the kill-switch); a human-cut tag still releases via the same workflow. Fixes the load-bearing **ldflags v-prefix bug** (`{{.Version}}` stamps `0.1.0`, which `x/mod/semver` rejects) that currently makes the shipped spec-14 update-notifier + `self update` treat a released binary as a dev build and never offer updates. Adds a grouped goreleaser changelog, a PR-title conventional-commit lint, and a CI `v0.*` guard (stay 0.x: BREAKING → minor, never 1.0.0). Optional `devstack release` maintainer wizard. *Everything else in this lane ships through this pipeline — build it first.* --- diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index f1a94c7..c31859e 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -73,7 +73,7 @@ Effort is **person-weeks at production OSS quality** (tests + docs + cross-platf > Specs: [25](specs/25-release-automation.md) (release automation) · [22](specs/22-init-wizard.md) (init wizard) · [23](specs/23-template-authoring.md) (template authoring) · [24](specs/24-env-ingestion.md) (.env ingestion). All TUIs are Bubble Tea v2 + Charm (`bubbles`/`lipgloss/v2`/`huh/v2`), CGO-free, behind one shared `internal/prompt` theme with a non-TTY/`--json` fallback. **M8.0 — Release automation + 0.x versioning (the v0.2.0 gate) · 0.75w thin (+0.75w wizard).** ([spec 25](specs/25-release-automation.md)) -- `.svu.yaml` (`v0: true`) + a `tag.yml` workflow: push-to-`main` → `svu next --v0` → push a `v*` tag (owner-gated `RELEASE_TOKEN` at **job** level so the kill-switch `if:` can read it) → the existing `release.yml`/goreleaser on the tag. +- `.svu.yaml` (`v0: true`) + a single rewritten `release.yml`: push-to-`main` → `svu next --v0` → tag + goreleaser **in one job** using the built-in `GITHUB_TOKEN` (no PAT), gated on the `RELEASE_ENABLED` repo variable; the same workflow also releases a human-cut `v*` tag. - Fix the ldflags v-prefix bug (un-mutes the shipped spec-14 self-update/notifier); add a grouped goreleaser changelog (`use: github` + `groups`/`filters`); PR-title conventional-commit lint; CI `v0.*` guard. - Optional: the `devstack release` maintainer wizard + an additive `update.channel` (stable|prerelease) knob (extends spec 14, does not redefine it). - **Sequence first** — every later item ships through it; the thin slice (~0.75w) is all that is needed to cut v0.2.0. diff --git a/docs/specs/25-release-automation.md b/docs/specs/25-release-automation.md index 06d197e..d89d2fc 100644 --- a/docs/specs/25-release-automation.md +++ b/docs/specs/25-release-automation.md @@ -6,9 +6,9 @@ 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` tag → grouped changelog → goreleaser release**, wired so the existing tag-triggered release engine and `self update`/notifier ([spec 14](14-self-update-and-migration.md)) 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. ## Decisions -- **Version computation = `caarlos0/svu` (pure-Go, by the goreleaser author), always `--v0`.** CI runs `svu next --v0` to derive the next tag from conventional-commit history; the existing `release.yml` (`on: push: tags: ["v*"]`) stays the release engine. 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. +- **Version computation = `caarlos0/svu` (pure-Go, by the goreleaser author), always `--v0`.** CI runs `svu next --v0` to 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 --v0` makes 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 has `MAJOR != 0`. `.svu.yaml` sets `v0: true` so the flag can never be forgotten. -- **Two-workflow split, App-token tag push.** A new `tag.yml` (`on: push: main` + `workflow_dispatch`) computes the version and pushes the `v*` tag using a **GitHub App / fine-grained PAT** secret (`RELEASE_TOKEN`); the existing `release.yml` fires on the tag and runs goreleaser unchanged. Reason: a tag pushed with the default `GITHUB_TOKEN` does **not** re-trigger workflows (Actions suppresses events from `GITHUB_TOKEN`), so a plain bot push would never fire `release.yml`. The App token is owner-gated (least-privilege, no expiry) and **its absence is the kill-switch**: `tag.yml` computes-and-logs but **does not push** when `RELEASE_TOKEN` is unset, so a private / pre-public repo never auto-releases (honors the owner-only release-flip, [PROGRESS](../PROGRESS.md) decision #4). The secret is exposed at **job level** so the push step's `if:` can actually read it (a step-level `env:` is invisible to that same step's `if:`). +- **Single combined workflow, built-in `GITHUB_TOKEN`, no PAT/App token (owner decision).** One `release.yml` triggers on `push: main` (automated) **and** `push: tags: ["v*"]` (a human hand-cut tag) **and** `workflow_dispatch`. On a main push it runs `svu next --v0`, applies the 0.x guard, and — gated by the repo **variable** `RELEASE_ENABLED == 'true'` — tags **and** runs goreleaser **in the same job**. Compute and release run together *on purpose*: a tag pushed with the default `GITHUB_TOKEN` does **not** re-trigger workflows (Actions suppresses events from `GITHUB_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 only `contents: write` (which `GITHUB_TOKEN` grants) to create the Release. **Kill-switch = the `RELEASE_ENABLED` repo variable** (default unset ⇒ compute + log, never release), honoring the owner-only release-flip ([PROGRESS](../PROGRESS.md) decision #4) without a managed secret. A human `git tag vX.Y.Z && git push` still releases (the same workflow's tag trigger; human pushes are not suppressed and are ungated — explicit intent). *Rejected the two-workflow + `RELEASE_TOKEN` split: it keeps `release.yml` perfectly reusable but costs a no-expiry `contents:write` secret to manage — not worth it for a solo/beta project.* - **Single changelog source of truth = goreleaser, grouped by conventional type.** Upgrade `.goreleaser.yaml` `changelog` to `groups:` (Features/Fixes/Performance/…) with `filters.exclude` for `chore|docs|test|ci|build|style`. Grouping/filtering apply with `use: github` (and `use: git`) but are **ignored** under `use: github-native`, so the config must stay on `github`. `@semantic-release/release-notes-generator` is **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.yaml` ldflags from `version.Version={{.Version}}` to `version.Version=v{{ .Version }}` so the stamped `internal/version.Version` is a clean, **v-prefixed** semver that `x/mod/semver` accepts. The archive `name_template` stays on the **v-stripped** `{{ .Version }}` to match `assetName`'s `TrimPrefix` ([spec 14](14-self-update-and-migration.md), `update.go:78`). A CI step asserts the built binary's `version --short` output is `semver.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_request` types `opened|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. @@ -49,71 +49,85 @@ v0: true # KeepV0: a BREAKING change bumps the MINOR (0.1.x → 0.2.0), never ``` ```yaml -# .github/workflows/tag.yml — NEW. Computes + pushes the v* tag; release.yml then fires. -name: Tag +# .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], paths-ignore: ["**/*.md", "docs/**", "LICENSE", "NOTICE"] } + push: + branches: [main] + tags: ["v*"] + paths-ignore: ["**/*.md", "docs/**", "LICENSE", "NOTICE"] workflow_dispatch: {} -permissions: { contents: write } -concurrency: { group: tag-main, cancel-in-progress: false } # never race two taggers +permissions: { contents: write } # tag push + goreleaser Release, both via GITHUB_TOKEN +concurrency: { group: release-${{ github.ref }}, cancel-in-progress: false } jobs: - tag: + release: runs-on: ubuntu-latest - env: - RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }} # JOB-level: required so the push step's `if:` can read it steps: - uses: actions/checkout@v4 - with: { fetch-depth: 0 } # full history + tags for svu + 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) - # Resolve the real latest v3.x once and pin the exact tag here; the v3 - # module path is fixed, the patch must be confirmed before merge. - run: go install github.com/caarlos0/svu/v3@v3.2.3 + 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! must NEVER yield v1.0.0) + 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.*) : ;; + v0.*) echo "ok" ;; *) echo "::error::refusing non-0.x tag ${{ steps.svu.outputs.next }} while in BETA"; exit 1 ;; esac - - name: push tag (only when enabled + a real bump) - if: ${{ steps.svu.outputs.next != steps.svu.outputs.current && env.RELEASE_TOKEN != '' }} + - name: gate + tag (only when RELEASE_ENABLED + a real bump) + id: gate + if: ${{ !startsWith(github.ref, 'refs/tags/') }} run: | - git config user.name "devstack-release[bot]" - git config user.email "release@devstack.local" - git remote set-url origin "https://x-access-token:${RELEASE_TOKEN}@github.com/${{ github.repository }}.git" + 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 }}" # fires release.yml (App token ≠ GITHUB_TOKEN) + 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 }}" } ``` ## Behavior -**Automated release pipeline (CI):** +**Automated release pipeline (CI), all in one `release.yml` job:** 1. PR merges to `main` (squash; PR title is the commit, already conventional-lint-checked on the PR). -2. `tag.yml` checks out full history, installs the pinned `svu`, runs `svu next --v0` → e.g. `v0.2.0`. +2. `release.yml` (on `push: main`) checks out full history, installs the pinned `svu`, runs `svu next --v0` → e.g. `v0.2.0`. 3. **0.x guard:** if the computed tag is not `v0.*`, fail the job (a `feat!`/`BREAKING CHANGE` during BETA must bump the minor, not major). -4. If the computed tag differs from the latest existing tag **and** `RELEASE_TOKEN` is set, push the tag with the App token. Otherwise log "no release due" / "tagging disabled" and exit 0 (no push — the pre-public kill-switch). -5. The pushed `v*` tag triggers the unchanged `release.yml` → goreleaser `release --clean`: builds the 4 CGO-free targets, stamps `version.Version=v0.2.0`, emits the **grouped** changelog, archives (v-stripped names) + `checksums.txt` + `.deb`/`.rpm`, creates the GitHub Release. +4. **Gate:** if `vars.RELEASE_ENABLED != 'true'`, or the computed tag equals the latest tag, log and exit 0 (no release — the kill-switch). Otherwise `git tag` + `git push origin ` with `GITHUB_TOKEN` (this push does **not** re-trigger the workflow's own tag filter — GITHUB_TOKEN events are suppressed — so there is no double release). +5. **In the same job**, goreleaser `release --clean` runs (it sees the freshly created tag via `git describe`): builds the 4 CGO-free targets, stamps `version.Version=v0.2.0`, emits the **grouped** changelog, archives (v-stripped names) + `checksums.txt` + `.deb`/`.rpm`, creates the GitHub Release with `GITHUB_TOKEN`. 6. A user on v0.1.0 runs any command → the notifier's `LatestTag` resolves `v0.2.0` (v-prefixed `tag_name`), `semver.Compare("v0.2.0","v0.1.0")>0` → footer shown. `devstack self update` downloads `devstack_0.2.0__.tar.gz` (v-stripped), verifies `checksums.txt`, atomically replaces — all unchanged from [spec 14](14-self-update-and-migration.md), 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):** 1. Resolve current tag + `svu next --v0`; render a grouped changelog preview (commits since the last tag, bucketed by conventional type). 2. **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`. 3. **Non-TTY / `--yes` / `--json` / CI:** never start the TUI. `--json` prints `{current,next,bump,changelog,wouldPush}` and exits; `--dry-run` prints the plan and writes nothing; `--yes` tags non-interactively; `--no-push` tags locally only. 4. `release --check` prints the next version and exits **0 if a release is due**, **non-zero if not** (scriptable gate, reads as `if devstack release --check; then ...`). **Note the deliberate inversion:** unlike the drift-style `generate --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. `--quiet` suppresses 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 adding the `RELEASE_TOKEN` secret (or runs `devstack release --yes` / `git tag v0.2.0 && git push origin v0.2.0` manually). No `0.0.0` seeding hazard exists here — the repo already has a non-zero `v0.1.0` baseline. +**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. ## Verified constraints / gotchas - **The v-stripped ldflags is a silent muter, not cosmetic.** goreleaser's `{{ .Version }}` is the tag *without* the leading `v` (`0.2.0`), but `x/mod/semver` requires the `v` and treats malformed input as lowest. Confirmed against the code: `internal/selfupdate/selfupdate.go` `IsDevBuild(v)` returns true when `!semver.IsValid(v)`, and `notify.go`/`update.go` short-circuit on `IsDevBuild`. A goreleaser binary stamped `0.2.0` → `IsDevBuild=true` → **the notifier goes silent and `self update` never sees an update**. Fix the ldflags to `v{{ .Version }}`; keep the archive `name_template` v-stripped (it must match `assetName = devstack___.tar.gz`, `update.go:78`). These two opposite conventions are both load-bearing — do not "unify" them. -- **A bot tag pushed with `GITHUB_TOKEN` will not fire `release.yml`.** GitHub deliberately suppresses workflow events from the default token to prevent recursion. Use a GitHub App / fine-grained PAT (`RELEASE_TOKEN`) for the tag push, or collapse tag-compute + goreleaser into one job. The two-workflow + App-token split keeps `release.yml` untouched and owner-gated. **`release.yml` itself still uses `GITHUB_TOKEN`** for goreleaser (it only creates a release; it does not need to re-trigger anything), so it is unchanged. -- **A step-level `env:` is invisible to that step's own `if:`.** The `env` context in an `if:` expression resolves only workflow- and job-level env. Therefore `RELEASE_TOKEN` must be declared at **job level** (as in the `tag.yml` above), not on the push step, or the kill-switch `if: env.RELEASE_TOKEN != ''` always reads empty and the tag never pushes. +- **A tag pushed with `GITHUB_TOKEN` does 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: write` is enough to *push* a tag, but the push won't *wake* a separate tag-triggered `release.yml` — those are two different things. The chosen design dodges the problem entirely by running `svu`-compute → tag → goreleaser **in the same job**, so nothing depends on a re-trigger and the built-in `GITHUB_TOKEN` suffices (no PAT/App token). The alternative — a separate `tag.yml` pushing with a PAT/App `RELEASE_TOKEN` so the push *does* fire `release.yml` — is the standard "release bot" pattern but costs a managed `contents:write` secret; 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 a `vars.*` (not `secrets.*`) value, readable directly in `run:`/`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's `refs/tags/` branch. - **Only ever push `v` tags.** A non-semver tag (a channel/range/build-meta suffix, or a milestone label like `m2-done`) breaks the `release-dryrun` job's `goreleaser` `git describe` for *every* PR (ci.yml job `release-dryrun` runs `release --snapshot --clean`; [PROGRESS](../PROGRESS.md) decision #1). svu's default `tagFormat` `v${version}` satisfies this; `.svu.yaml` must never add a suffix. - **`--v0` is not the default — forget it and BETA breaks.** Plain `svu next` bumps a BREAKING change to 1.0.0. Always pass `--v0` (and set `v0: true` in `.svu.yaml`) **and** keep the CI `v0.*` guard as defense-in-depth. (Note the release-please trap if you ever switch tools: with a `0.0.0` manifest its `bump-*-pre-major` flags are ignored and it recommends 1.0.0 — you must seed at `0.1.0`. svu reading the existing `v0.1.0` tag avoids this entirely.) - **`x/mod/semver` orders 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`). Staying `MAJOR=0` keeps every notifier/self-update comparison valid; tag any beta as `vX.Y.Z-beta.N` so it sorts *before* the final. @@ -124,9 +138,9 @@ jobs: - **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-vendored `github.com/compose-spec/compose-go/v2/dotenv` — not `joho/godotenv`/`hashicorp/go-envparse`. ## Acceptance criteria -- [ ] Merging a `feat:` PR to `main` makes `tag.yml` compute `v0.2.0` via `svu next --v0`; with `RELEASE_TOKEN` set it pushes the tag, which triggers `release.yml` → a GitHub Release with grouped (Features/Fixes/…) notes. +- [ ] Merging a `feat:` PR to `main` with `RELEASE_ENABLED=true` makes `release.yml` compute `v0.2.0` via `svu next --v0`, tag it, and run goreleaser **in the same job** → a GitHub Release with grouped (Features/Fixes/…) notes, using only `GITHUB_TOKEN`. - [ ] A `feat!:` / `BREAKING CHANGE` commit while in BETA computes a **minor** bump (e.g. `v0.2.0`), never `v1.0.0`; the CI `v0.*` guard fails the run if any non-0.x tag would be produced. -- [ ] When `RELEASE_TOKEN` is unset, `tag.yml` logs the computed version and exits 0 **without pushing** (pre-public kill-switch); when the computed tag equals the latest existing tag it also no-ops. The push-step `if:` resolves `RELEASE_TOKEN` from job-level env (a deliberate test: an empty secret must skip the push). +- [ ] When `RELEASE_ENABLED` is unset/not `true`, `release.yml` logs 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-cut `git tag vX.Y.Z && git push` releases via the same workflow's `refs/tags/` path with no variable and no token. - [ ] A goreleaser-built binary reports `version --short` = a v-prefixed string that `semver.IsValid` accepts (verified on both a real tag and the `--snapshot` build, whose `v0.1.1-dev-` is still valid semver); a CI step asserts it. The release archive filename stays v-stripped (`devstack_0.2.0__.tar.gz`). - [ ] After v0.2.0 is published, a v0.1.0 user sees the notifier footer and `self update` installs v0.2.0 (checksum-verified) with no other change to [spec 14](14-self-update-and-migration.md) code — i.e. the previously-muted path now fires. - [ ] A PR whose title is not `type(scope)?: subject` fails the PR-title lint; a conventional title passes. No Node toolchain is added to CI. @@ -137,10 +151,10 @@ jobs: - [ ] Adding `charm.land/huh/v2` + transitive bubbletea/bubbles v2 keeps `CGO_ENABLED=0 go build ./...`, the 4-target cross-compile, and `make vuln` green; no charm v1 packages enter `go.mod`. ## Dependencies / consumers -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](06-git.md), 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](14-self-update-and-migration.md)/[spec 01](01-config-schema.md)). 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](14-self-update-and-migration.md)) is the direct beneficiary — the v-prefix fix and the `update.channel` knob make its notifier + `self update` actually reachable. CI consumers: `tag.yml` (new), `release.yml` (unchanged engine), `ci.yml` (new PR-title lint + the `version --short` semver assertion; 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: `charm.land/huh/v2` (latest v2.0.x) + transitive `charm.land/bubbletea/v2` / `charm.land/bubbles/v2` (v2 line). **Thin vs full:** the thin slice (`.svu.yaml` + `tag.yml` + ldflags/changelog fix + 0.x guard + PR-title lint) is ~0.75w and is all that's needed to cut v0.2.0; the `devstack release` huh wizard + `internal/release`/`internal/prompt` + `update.channel` filter add ~0.75w. +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](06-git.md), 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](14-self-update-and-migration.md)/[spec 01](01-config-schema.md)). 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](14-self-update-and-migration.md)) 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. ## Open questions -- **Orchestration shape — two-workflow (App-token) vs single-job.** Two workflows keep `release.yml` untouched and the owner kill-switch clean, at the cost of one App-token secret. A single job (svu + goreleaser together) needs no extra token but folds tagging into the release run. *Recommendation: two-workflow + `RELEASE_TOKEN`.* **Decision:** two-workflow split; revisit only if App-token management proves heavier than the recursion-avoidance it buys. +- **Orchestration shape — two-workflow (App-token) vs single-job.** Two workflows keep `release.yml` reusable 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 + the `RELEASE_ENABLED` repo variable, built-in `GITHUB_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.Scanner` y/n prompt behind the same `internal/prompt` interface 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:** keep `internal/prompt` as the seam either way; default to stdlib, leave huh as a drop-in upgrade. - **Committed `CHANGELOG.md` vs release-notes-only.** goreleaser grouped notes live on the GitHub Release; a committed `CHANGELOG.md` would need git-cliff/semantic-release (a second generator) and a bot commit to `main`. *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 generated `CHANGELOG.md` artifact (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.