Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions .github/workflows/pr-title.yml
Original file line number Diff line number Diff line change
@@ -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 (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).
#
# 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
78 changes: 75 additions & 3 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -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
Expand Down
20 changes: 19 additions & 1 deletion .goreleaser.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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}}

Expand Down Expand Up @@ -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 at release time.
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 ']
11 changes: 11 additions & 0 deletions .svu.yaml
Original file line number Diff line number Diff line change
@@ -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 release.yml backs this up.
#
# Keep this file MINIMAL. svu's DEFAULT tag format is already `v<semver>`; 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
20 changes: 20 additions & 0 deletions docs/FEATURES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` → 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.*

---

## At a glance
Expand All @@ -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) |
25 changes: 25 additions & 0 deletions docs/ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 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.

**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
Expand All @@ -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.

Expand Down
Loading
Loading