Skip to content

Commit 01fa063

Browse files
feat(release): semantic-release versioning + self-update v-prefix fix (spec 25) (#79)
* 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) <noreply@anthropic.com> * 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-<sha>. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * 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) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 791feda commit 01fa063

10 files changed

Lines changed: 826 additions & 4 deletions

File tree

.github/workflows/pr-title.yml

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
name: PR title
2+
3+
# Conventional-commit lint for PR titles (spec 25). The repo squash-merges, so the
4+
# PR title becomes the commit subject that svu (release.yml) reads to compute the next
5+
# version — an unconventional title silently corrupts version computation. Pure
6+
# shell, no Node toolchain (consistent with the rest of CI).
7+
#
8+
# Repo setting to pair with this: squash-merge only, with "Default to PR title" for
9+
# the squash commit message.
10+
11+
on:
12+
pull_request:
13+
types: [opened, edited, synchronize, reopened]
14+
15+
permissions:
16+
contents: read
17+
18+
concurrency:
19+
group: pr-title-${{ github.event.pull_request.number }}
20+
cancel-in-progress: true
21+
22+
jobs:
23+
lint:
24+
runs-on: ubuntu-latest
25+
steps:
26+
- name: check conventional-commit subject
27+
# Pass the title via env (never inline-interpolated into the script) so a
28+
# crafted PR title cannot inject shell.
29+
env:
30+
PR_TITLE: ${{ github.event.pull_request.title }}
31+
run: |
32+
set -eu
33+
pattern='^(feat|fix|docs|chore|ci|refactor|test|perf|build|style|revert)(\([a-z0-9._-]+\))?!?: .+'
34+
if printf '%s' "$PR_TITLE" | grep -qiE "$pattern"; then
35+
echo "ok: \"$PR_TITLE\""
36+
else
37+
echo "::error title=Non-conventional PR title::\"$PR_TITLE\" must be type(scope)?: subject"
38+
echo "Allowed types: feat fix docs chore ci refactor test perf build style revert"
39+
echo "Examples: 'feat(init): add shared-service picker' | 'fix(release): stamp v-prefixed version'"
40+
echo "Note: only feat (minor) / fix|perf (patch) / a '!' or BREAKING CHANGE bump the 0.x version."
41+
exit 1
42+
fi

.github/workflows/release.yml

Lines changed: 75 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,96 @@
11
name: Release
22

3+
# Single-workflow release (spec 25), built-in GITHUB_TOKEN only — no PAT/App token.
4+
#
5+
# Two entry points, one job:
6+
# • push to main → svu computes the next 0.x version from conventional
7+
# commits and, IF enabled, tags + releases in THIS job.
8+
# • push of a v* tag → a human cut a tag by hand → just run goreleaser.
9+
#
10+
# Tag-compute and goreleaser run together ON PURPOSE: GitHub suppresses workflow
11+
# events triggered by GITHUB_TOKEN, so a tag pushed here does NOT re-trigger this
12+
# workflow (no double release) — which is exactly why a split tag→release setup
13+
# would have needed a separate token. We avoid the token by never depending on
14+
# that re-trigger.
15+
#
16+
# Kill-switch (automated path only): the repository variable RELEASE_ENABLED.
17+
# Unset/anything-but-"true" (the default) ⇒ compute + log, never release. Enable
18+
# once with: gh variable set RELEASE_ENABLED --body true
19+
# A manual `git tag vX.Y.Z && git push` always releases (human pushes are not
20+
# suppressed and are not gated — explicit intent).
21+
322
on:
423
push:
24+
branches: [main]
525
tags: ["v*"]
26+
paths-ignore: ["**/*.md", "docs/**", "LICENSE", "NOTICE"]
27+
workflow_dispatch: {}
628

729
permissions:
8-
contents: write # create the GitHub release + upload artifacts
30+
contents: write # tag push + goreleaser GitHub Release, both via GITHUB_TOKEN
31+
32+
concurrency:
33+
group: release-${{ github.ref }}
34+
cancel-in-progress: false
935

1036
jobs:
11-
goreleaser:
37+
release:
1238
runs-on: ubuntu-latest
1339
steps:
1440
- uses: actions/checkout@v4
1541
with:
16-
fetch-depth: 0 # goreleaser needs full history + tags
42+
fetch-depth: 0 # full history + tags for svu + goreleaser
1743
- uses: actions/setup-go@v5
1844
with:
1945
go-version: "1.25"
2046
check-latest: true
47+
48+
# --- automated path (push to main / workflow_dispatch): compute + tag ---
49+
- name: install svu (pinned)
50+
if: ${{ !startsWith(github.ref, 'refs/tags/') }}
51+
run: go install github.com/caarlos0/svu/v3@v3.4.1
52+
- name: compute next version
53+
id: svu
54+
if: ${{ !startsWith(github.ref, 'refs/tags/') }}
55+
run: |
56+
CUR="$(git describe --tags --abbrev=0 2>/dev/null || echo v0.0.0)"
57+
NEXT="$(svu next --v0)"
58+
echo "current=$CUR" >> "$GITHUB_OUTPUT"
59+
echo "next=$NEXT" >> "$GITHUB_OUTPUT"
60+
echo "svu: $CUR -> $NEXT"
61+
- name: 0.x guard (a stray feat!/BREAKING must NEVER yield v1.0.0 while in BETA)
62+
if: ${{ !startsWith(github.ref, 'refs/tags/') }}
63+
run: |
64+
case "${{ steps.svu.outputs.next }}" in
65+
v0.*) echo "ok: ${{ steps.svu.outputs.next }} is on the 0.x beta line" ;;
66+
*) echo "::error::refusing non-0.x tag ${{ steps.svu.outputs.next }} while in BETA"; exit 1 ;;
67+
esac
68+
- name: gate + tag (only when enabled + a real bump)
69+
id: gate
70+
if: ${{ !startsWith(github.ref, 'refs/tags/') }}
71+
run: |
72+
if [ "${{ vars.RELEASE_ENABLED }}" != "true" ]; then
73+
echo "go=false" >> "$GITHUB_OUTPUT"
74+
echo "releases disabled: set repo variable RELEASE_ENABLED=true to enable (computed ${{ steps.svu.outputs.next }})"
75+
exit 0
76+
fi
77+
if [ "${{ steps.svu.outputs.next }}" = "${{ steps.svu.outputs.current }}" ]; then
78+
echo "go=false" >> "$GITHUB_OUTPUT"
79+
echo "no release due: no version-bumping commits since ${{ steps.svu.outputs.current }}"
80+
exit 0
81+
fi
82+
git config user.name "devstack-release[bot]"
83+
git config user.email "release@devstack.local"
84+
git tag "${{ steps.svu.outputs.next }}"
85+
# GITHUB_TOKEN push: does NOT re-trigger this workflow's tag filter (so no
86+
# double release); we run goreleaser below in this same job.
87+
git push origin "${{ steps.svu.outputs.next }}"
88+
echo "go=true" >> "$GITHUB_OUTPUT"
89+
echo "tagged + pushed ${{ steps.svu.outputs.next }}"
90+
91+
# --- release: on a manual tag push, or right after auto-tagging ---
2192
- uses: goreleaser/goreleaser-action@v6
93+
if: ${{ startsWith(github.ref, 'refs/tags/') || steps.gate.outputs.go == 'true' }}
2294
with:
2395
version: "~> v2"
2496
args: release --clean

.goreleaser.yaml

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,15 @@ builds:
2020
- -trimpath
2121
ldflags:
2222
- -s -w
23-
- -X github.com/open-source-cloud/devstack/internal/version.Version={{.Version}}
23+
# Stamp a `v`-PREFIXED semver. goreleaser's {{.Version}} is the tag with the
24+
# leading `v` STRIPPED ("0.2.0"); golang.org/x/mod/semver requires the `v`
25+
# and treats malformed input as lowest, so a stripped string makes
26+
# internal/selfupdate's IsDevBuild() return true → the update notifier +
27+
# `self update` silently treat a real release as a dev build and never fire
28+
# (spec 25). The archive name_template below stays v-STRIPPED on purpose, to
29+
# match assetName's TrimPrefix in internal/selfupdate (spec 14) — the two
30+
# opposite conventions are both load-bearing; do not "unify" them.
31+
- -X github.com/open-source-cloud/devstack/internal/version.Version=v{{ .Version }}
2432
- -X github.com/open-source-cloud/devstack/internal/version.Commit={{.ShortCommit}}
2533
- -X github.com/open-source-cloud/devstack/internal/version.Date={{.Date}}
2634

@@ -63,5 +71,15 @@ snapshot:
6371
version_template: "{{ incpatch .Version }}-dev-{{ .ShortCommit }}"
6472

6573
changelog:
74+
# MUST stay `github` (or `git`): `groups`/`filters` below are IGNORED under
75+
# `github-native`. goreleaser is the SINGLE source of release notes (no second
76+
# generator) — it buckets the conventional-commit history at release time.
6677
use: github
6778
sort: asc
79+
groups:
80+
- { title: "Features", regexp: '^.*?feat(\(.+\))?!?:.*$', order: 0 }
81+
- { title: "Bug fixes", regexp: '^.*?fix(\(.+\))?!?:.*$', order: 1 }
82+
- { title: "Performance", regexp: '^.*?perf(\(.+\))?!?:.*$', order: 2 }
83+
- { title: "Others", order: 99 }
84+
filters:
85+
exclude: ['^chore', '^docs', '^test', '^ci', '^build', '^style', '^Merge ']

.svu.yaml

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
# svu (caarlos0/svu) config — drives conventional-commit → semver in CI (spec 25).
2+
#
3+
# v0 is NON-NEGOTIABLE while the project is in BETA: KeepV0 makes a BREAKING
4+
# change bump the MINOR (0.1.x → 0.2.0), never 1.0.0. Reaching 1.0.0 is a
5+
# deliberate, manual owner decision (drop this flag, cut v1.0.0 by hand) — it is
6+
# never produced by automation. A CI `v0.*` guard in release.yml backs this up.
7+
#
8+
# Keep this file MINIMAL. svu's DEFAULT tag format is already `v<semver>`; do NOT
9+
# add a tag prefix/suffix or build-meta here — a non-semver tag breaks the
10+
# release-dryrun job's `goreleaser` for every PR (PROGRESS decision #1).
11+
v0: true

docs/FEATURES.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,22 @@ Strictly opt-in (explicit first-run prompt, default OFF, one-flag disable, docum
6161
**13. Remote / cloud shared-services backend · 8w.** ([spec 21](specs/21-remote-shared-backend.md))
6262
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.*
6363

64+
### Beta DX lane (post-M7, ships in the v0.2.x 0.x line)
65+
66+
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.
67+
68+
**14. Interactive `init` wizard · 2w — the file-authoring front door.** ([spec 22](specs/22-init-wizard.md))
69+
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.*
70+
71+
**15. Interactive template & Dockerfile authoring (TUI) · 2.5w.** ([spec 23](specs/23-template-authoring.md))
72+
`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.*
73+
74+
**16. `.env` ingestion → secrets/vars · 2.5w.** ([spec 24](specs/24-env-ingestion.md))
75+
`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.*
76+
77+
**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))
78+
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.*
79+
6480
---
6581

6682
## At a glance
@@ -80,3 +96,7 @@ Generalize the shared stack to run on a remote Docker host (DOCKER_HOST/SSH cont
8096
| 11 | Versioned template registry | 2.5w | v2 · [spec 19](specs/19-template-registry.md) |
8197
| 12 | Opt-in telemetry | 1.5w | later · [spec 20](specs/20-telemetry.md) |
8298
| 13 | Remote/cloud shared backend | 8w | later · [spec 21](specs/21-remote-shared-backend.md) |
99+
| 14 | Interactive `init` wizard (TUI) | 2w | v0.2 beta DX (M8) · [spec 22](specs/22-init-wizard.md) |
100+
| 15 | Template & Dockerfile authoring (TUI) | 2.5w | v0.2 beta DX (M8) · [spec 23](specs/23-template-authoring.md) |
101+
| 16 | `.env` ingestion → secrets/vars | 2.5w | v0.2 beta DX (M8) · [spec 24](specs/24-env-ingestion.md) |
102+
| 17 | Release automation + 0.x versioning | 0.75w+0.75w | v0.2 gate (M8) · [spec 25](specs/25-release-automation.md) |

docs/ROADMAP.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,30 @@ Effort is **person-weeks at production OSS quality** (tests + docs + cross-platf
6868
- macOS arm64 CI runner for trust/resolver/Desktop-VM behavior; cache GC; document Docker Desktop licensing + Podman/rootless out-of-scope.
6969
- Quickstart + migration guide, secrets threat model, troubleshooting; goreleaser tap + `.deb`/`.rpm`; tag 1.0.
7070

71+
### M8 — Beta DX & release-engineering lane (post-M7, v0.2.x 0.x line) · **~8w**
72+
> 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.
73+
> 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.
74+
75+
**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))
76+
- `.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.
77+
- 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.
78+
- Optional: the `devstack release` maintainer wizard + an additive `update.channel` (stable|prerelease) knob (extends spec 14, does not redefine it).
79+
- **Sequence first** — every later item ships through it; the thin slice (~0.75w) is all that is needed to cut v0.2.0.
80+
81+
**M8.1 — `internal/prompt` + interactive `init` · 2w.** ([spec 22](specs/22-init-wizard.md))
82+
- 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).
83+
- `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.
84+
85+
**M8.2 — `template new` authoring TUI · 2.5w.** ([spec 23](specs/23-template-authoring.md))
86+
- 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.
87+
- 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.
88+
89+
**M8.3 — `.env` ingestion + secrets Pusher · 2.5w.** ([spec 24](specs/24-env-ingestion.md))
90+
- `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.
91+
- Add a `doctor` probe for the `sops` binary min-version (stdin / `--input-type` support).
92+
93+
**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.
94+
7195
---
7296

7397
## Totals
@@ -80,6 +104,7 @@ Effort is **person-weeks at production OSS quality** (tests + docs + cross-platf
80104
| Onboarding/glue (M6) | 8 | +~2.5 months |
81105
| Hardening/GA (M7) | 6 | +~2 months |
82106
| **Full v1 (all pillars)** | **54** | **~13–15 months** |
107+
| Beta DX lane (M8, 0.x — post-GA) | ~8 | +~2.5 months |
83108

84109
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.
85110

0 commit comments

Comments
 (0)