diff --git a/.abcd/development/brief/04-surfaces/04-launch.md b/.abcd/development/brief/04-surfaces/04-launch.md
index e169c9e2..394d0117 100644
--- a/.abcd/development/brief/04-surfaces/04-launch.md
+++ b/.abcd/development/brief/04-surfaces/04-launch.md
@@ -7,6 +7,7 @@
The shipped verb surface is the `--dry-run` flag on `abcd launch` — a read-only preview of the bundle and gates — plus the `abcd launch ship` subcommand, which cuts the release (the RELEASE CUT slice only; see the ship bullet below). Bare `abcd launch` never mutates state: it refuses (exit 1) with a hint to pass `--dry-run`; a bare-as-status render is a design target, unshipped. The sub-verb design:
- **`/abcd:launch ship`** — **partly shipped: the RELEASE CUT only** (itd-73 derived versioning + itd-67's changelog slice). `abcd launch ship` derives the version and the record set from what shipped since the newest tag, runs the surface guardrail, and — with `--changelog-json` — validates the host-composed prose against the record set (the completeness bijection) and writes the dated `CHANGELOG.md` heading `.github/workflows/auto-release.yml` turns into a tag; `commands/abcd/launch.md` carries the emit → compose → ingest orchestration and the `release-changelog-composer` agent it dispatches. The `Ship` engine is wired: `abcd launch ship` is a live subcommand, and `--payload-dir
` stages the versioned release payload — the derived version stamped into the payload's `plugin.json`/`marketplace.json` and lockstep-proved before return. Commit, tag, and publish stay a design target (itd-65 gate suite + itd-72 publishing): the verb neither commits, tags, nor publishes. The full-cut design: cut a curated release artefact from the one repo: run pre-flight gates, filter the artefact (default-deny, `.abcd/**` excluded by packaging), stamp the version, and on a `v*` tag publish a GitHub Release ([adr-28](../../decisions/adrs/0028-single-repo-curated-release.md)). The flow described in §§ 1–6 below is this sub-verb's behaviour. Flag-shaped modifiers `--allow-dirty` and `--allow-doc-warnings` belong to this sub-verb's design; the shipped `ship` accepts `--changelog-json` and `--payload-dir` (plus the global `--json`), and bare `abcd launch` accepts only `--dry-run` and the global `--json`. There is no version flag — the version is derived, never authored ([adr-31](../../decisions/adrs/0031-derived-versioning-from-intents.md), see [§ 3](#3-versioning--marketplace)).
+- **`/abcd:launch scaffold`** — **shipped** (itd-93, spc-14). `abcd launch scaffold` writes the changelog-driven release machinery — `.github/workflows/release.yml`, `.github/workflows/auto-release.yml`, and the adr-37 release runbook (`.abcd/development/release-gate/README.md`) — into a managed repo that lacks it, wired to the repo's own default branch and Go version, `GITHUB_TOKEN`-only and injection-safe. The workflows ship from a single embedded template that abcd-cli's own release workflows are regenerated from (self-scaffold parity, a byte-exact test); the scaffolded `release.yml` carries a `workflow_dispatch` **rehearsal** that arms the full gate against a simulated changelog roll and reviewed-content commit and publishes nothing, so a green rehearsal is the runbook precondition for the first real release. A bare repo with no semantic detector degrades cleanly to the deterministic gates and a generic build. It is idempotent and fail-safe: a re-run on current machinery is a no-op (exit 0), a hand-edited file is refused (exit 1) rather than clobbered unless `--confirm` is passed, and a structural fault exits 2. `commands/abcd/launch.md` carries the flow. Accepts `--confirm` plus the global `--json`.
- **`/abcd:launch dry-run`** — shipped as the `--dry-run` flag (the plugin command `commands/abcd/launch.md` maps the `dry-run` of its `[dry-run | ship]` argument hint onto `abcd launch --dry-run`; the binary has no `dry-run` subcommand). **Report-only preview, always exit-0** (a preview never blocks). It runs the parts of the pre-flight suite that exist today: as of spc-64 (predecessor store) the **secret + PII scan gate** (the native scanners, see [§ 1](#1-pre-flight-gates)) runs for real in report-only mode and prints what it *would* refuse on (a finding, or a fail-closed reason such as "scanner unavailable"); the **installability smoke** runs for real at its light tier (see [§ 1](#1-pre-flight-gates)); the **manifest lockstep check** runs for real at its `dev` polarity over the working tree — the polarity adr-19 requires the committed manifests to satisfy (no version key), see [§ 3](#3-versioning--marketplace) — reports its result and folds any drift or an unreadable version-location contract into what it *would* refuse on; the remaining gates (marker-block, documentation-auditor) are the gate-suite intent's (itd-65): `--dry-run --json` reports each as `{"status": "not_implemented", "detail": "Phase-5 deferred"}`, while the plain-text `--dry-run` render omits the gate list entirely (it prints only version, files bundled, scan hardfails, and would-publish, plus a would-refuse-on line when there is a finding). It also produces the would-be artefact manifest, without writing the release artefact. dry-run is **not** "ship minus publish": running the *full* gate suite and **hard-failing** on a finding (exit non-zero) is the full `ship` verb's behaviour (itd-65 + itd-72), not dry-run's.
## 1. Pre-flight gates
diff --git a/.abcd/development/release/surface.json b/.abcd/development/release/surface.json
index e99a05b7..e40c0b29 100644
--- a/.abcd/development/release/surface.json
+++ b/.abcd/development/release/surface.json
@@ -467,6 +467,19 @@
}
]
},
+ {
+ "path": "abcd launch scaffold",
+ "hidden": false,
+ "flags": [
+ {
+ "name": "confirm",
+ "shorthand": "",
+ "type": "bool",
+ "required": false,
+ "hidden": false
+ }
+ ]
+ },
{
"path": "abcd launch ship",
"hidden": false,
diff --git a/.abcd/work/DECISIONS.md b/.abcd/work/DECISIONS.md
index f011f061..038828c6 100644
--- a/.abcd/work/DECISIONS.md
+++ b/.abcd/work/DECISIONS.md
@@ -860,3 +860,4 @@ parallel-agent merge contention bites.
- 2026-07-27 (salvage): the four 2026-07-13/14 ideation-session entries above are appended out of chronological order — recovered from an unmerged ideation branch during branch cleanup, together with their research/plan docs; the /abcd:ideate seed from that session is re-recorded as itd-104 (its branch-local iss-93 capture id had collided with main's iss-93 and is retired unused).
- 2026-07-27 — DEFERRED the row-has-footnote structural docs-lint rule (spc-15 out-of-scope): no existing rule shape covers table-row-to-footnote structure, so it would need a bespoke check; the grill left it optional and the citation-gate intent (itd-101) is now its natural home — implement it there or not at all. Recorded per spc-15's deferral-is-recorded clause; surfaced by the itd-100 fidelity audit's gap check.
- 2026-07-27 — ADOPTED the canonical tagline: "A host-agnostic configuration layer for intent-driven development." Canonical identity home is the brief product chapter (01-product README, Identity section: title / tagline / pitch); README strapline, plugin manifest description, and AGENTS.md opening render from it. README's former strapline ("An opinionated, intent-driven development framework for product thinkers") is retired as a surface line; the product-thinker framing lives on in the README body. Rejected wordings: "for agent harnesses" (object shift, host/harness redundancy, plural over-promise), "over any agent harness" (drops the domain). Resolves iss-143; itd-102 generalises the drift check for managed repos.
+- 2026-07-27 — itd-93 parity tension resolved: Branch A. The scaffold template is the single source for release.yml/auto-release.yml; abcd-cli's live workflows are regenerated from it; the only sanctioned live diff is the additive workflow_dispatch rehearsal (trigger + non-publishing dry-run job). Rejected: substitution-gating the rehearsal off for abcd (weakens the parity guarantee on the one novel component).
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 6d91a9c8..f7f650cc 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -33,6 +33,14 @@ on:
required: false
type: string
default: ''
+ # Manual rehearsal. A workflow_dispatch runs ONLY the `rehearsal` job below and
+ # nothing that publishes: the publish job (`release`) is gated off this event,
+ # and the rehearsal arms the full gate against a simulated changelog roll and
+ # reviewed-content commit, proves the gate admits it, and creates no tag,
+ # Release, or attestation. Run it green once before the first real release (see
+ # the release-gate runbook) — it closes the private->public activation gap that
+ # otherwise surfaces only when the gate first meets a real tag.
+ workflow_dispatch:
# The released tag name. workflow_call: inputs.tag (the tag auto-release just
# made). push-tag: inputs is empty, so this falls through to github.ref_name
@@ -128,6 +136,10 @@ jobs:
# artefacts match the code that passed — even if the tag were re-pointed since.
release:
needs: verify
+ # The publish path never runs on a manual rehearsal (workflow_dispatch): that
+ # event runs the `rehearsal` job, which publishes nothing. The tag-push and
+ # workflow_call (auto-release) entry points are unaffected.
+ if: github.event_name != 'workflow_dispatch'
timeout-minutes: 20
runs-on: ubuntu-latest
permissions:
@@ -343,3 +355,96 @@ jobs:
env:
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+
+ # The manual rehearsal (workflow_dispatch). It arms the full gate against a
+ # SIMULATED changelog roll and reviewed-content commit — reproducing the
+ # two-commit release-branch shape and the auto-release merge — proves the gate
+ # resolves and admits, and PUBLISHES NOTHING: it holds contents: read only (so
+ # it cannot create a tag, Release, or attestation even by mistake), pushes
+ # nothing, and every mutation stays in the runner's throwaway checkout. A green
+ # rehearsal is the runbook precondition for the first real release.
+ rehearsal:
+ if: github.event_name == 'workflow_dispatch'
+ timeout-minutes: 15
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ steps:
+ - name: Check out (full history)
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ with:
+ # Full history so the content-commit resolution below can walk parents,
+ # exactly as release.yml's semantic gate does.
+ fetch-depth: 0
+ persist-credentials: false
+
+ - name: Set up Go
+ uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6
+ with:
+ go-version: '1.25'
+ cache: false
+
+ - name: Simulate a changelog roll and the two-commit release branch
+ # No ${{ }} interpolation (injection-safe): the script writes only the
+ # runner's throwaway checkout and $GITHUB_ENV. Nothing here is pushed.
+ run: |
+ set -euo pipefail
+ git config user.name 'rehearsal[bot]'
+ git config user.email 'rehearsal@localhost'
+ base="$(git rev-parse --verify HEAD)"
+ date="$(date -u +%Y-%m-%d)"
+ # Content commit on a scratch branch: simulate rolling [Unreleased] into a
+ # dated heading — the reviewed content commit a real release names.
+ git switch -q -c rehearsal-sim
+ if grep -q '^## \[Unreleased\]' CHANGELOG.md 2>/dev/null; then
+ awk -v d="$date" '1; /^## \[Unreleased\]/ && !done {print ""; print "## [0.0.0-rehearsal] - " d; done=1}' CHANGELOG.md > CHANGELOG.rehearsal
+ mv CHANGELOG.rehearsal CHANGELOG.md
+ else
+ printf '\n## [0.0.0-rehearsal] - %s\n' "$date" >> CHANGELOG.md
+ fi
+ git add -A
+ git commit -q -m 'rehearsal: simulated changelog roll (content commit)'
+ content="$(git rev-parse --verify HEAD)"
+ # Receipts commit: names the content commit and sits one commit later —
+ # the two-commit shape that keeps the receipt-vs-tag self-reference from
+ # ever blocking the release.
+ mkdir -p ".abcd/work/reviews/$content"
+ printf '{"rehearsal":true,"content":"%s"}\n' "$content" > ".abcd/work/reviews/$content/rehearsal.json"
+ git add -A
+ git commit -q -m 'rehearsal: simulated receipts commit'
+ # Merge into the base, reproducing the auto-release merge whose second
+ # parent is the receipts commit (so HEAD^2^ resolves the content commit).
+ git switch -q -c rehearsal-merge "$base"
+ git merge -q --no-ff -m 'rehearsal: merge simulated release' rehearsal-sim
+ echo "REHEARSAL_CONTENT_SHA=$content" >> "$GITHUB_ENV"
+
+ - name: Resolve the reviewed content commit (release.yml's derivation)
+ # Mirrors release.yml's resolve step exactly: HEAD^2^ on the merge path,
+ # HEAD^ on a direct tag. Asserts the resolution lands on the simulated
+ # content commit and is an ancestor of the released commit.
+ run: |
+ set -euo pipefail
+ released="$(git rev-parse --verify HEAD)"
+ if git rev-parse -q --verify "HEAD^2" >/dev/null 2>&1; then
+ content="$(git rev-parse --verify "HEAD^2^")"
+ else
+ content="$(git rev-parse --verify "HEAD^")"
+ fi
+ test -n "$content"
+ git merge-base --is-ancestor "$content" "$released"
+ test "$content" = "${REHEARSAL_CONTENT_SHA}"
+ echo "rehearsal: the gate would arm against content commit $content (present in the released tree)"
+
+ - name: Arm the deterministic record gate against the simulated tree
+ # The deterministic half of the gate must pass on the simulated roll. The
+ # semantic half (record-lint --release-gate) activates with the real gate on
+ # the public flip; here the resolution above proves it would arm correctly.
+ run: go run ./cmd/record-lint
+
+ - name: Prove the gate admits and nothing was published
+ run: |
+ set -euo pipefail
+ # Build the simulated tree — the deterministic leg the gate requires.
+ go build ./...
+ echo "rehearsal: full gate armed against the simulated roll; the gate admits."
+ echo "rehearsal: nothing published — no tag, Release, or attestation was created (contents: read)."
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 60c0a7ec..4130a660 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -12,6 +12,24 @@ called out in a **Breaking** section.
### Added
+- **`abcd launch scaffold` — the changelog-driven release-gate scaffolder**
+ (itd-93, spc-14). Writes the fixed release machinery into a managed repo that
+ lacks it: `.github/workflows/release.yml` (verify → build → publish, the verify
+ gate armed against the reviewed **content** commit so the first public release
+ cannot hit the receipt-vs-tag self-reference), `.github/workflows/auto-release.yml`
+ (newest dated CHANGELOG heading → tag that commit → call `release.yml`), and the
+ adr-37 runbook — wired to the repo's own default branch and Go version,
+ `GITHUB_TOKEN`-only and injection-safe. The workflows ship from a **single
+ embedded template** that abcd-cli's own release workflows are regenerated from
+ (self-scaffold parity, proven by a byte-exact test), so every abcd release
+ exercises the exact machinery a managed repo receives. The scaffolded
+ `release.yml` carries a `workflow_dispatch` **rehearsal** that arms the full gate
+ against a simulated changelog roll and reviewed-content commit and publishes
+ nothing — a green rehearsal is the runbook precondition for the first real
+ release. A bare repo with no semantic detector degrades cleanly to the
+ deterministic gates and a generic build. The verb is idempotent and fail-safe: a
+ re-run on current machinery is a no-op, a hand-edited file is refused rather than
+ clobbered (unless `--confirm`), and it refuses rather than half-writing.
- **A public terminology crosswalk at `docs/reference/terminology.md`** (itd-100).
One reference page maps 26 established agentic-AI terms — protocols, the core
loop, context, safety, governance, operations — to abcd's position on each:
diff --git a/commands/abcd/launch.md b/commands/abcd/launch.md
index 44ed6a58..7fe60743 100644
--- a/commands/abcd/launch.md
+++ b/commands/abcd/launch.md
@@ -1,7 +1,7 @@
---
name: launch
-description: Preview the public launch — the file bundle, the secret/PII scan, and the release gates — in dry-run mode, and cut a release by deriving its version and composing its changelog. The preview performs zero writes; `ship` writes the dated CHANGELOG heading and never publishes.
-argument-hint: "[dry-run | ship]"
+description: Preview the public launch — the file bundle, the secret/PII scan, and the release gates — in dry-run mode, cut a release by deriving its version and composing its changelog, and scaffold the changelog-driven release gate into a managed repo. The preview performs zero writes; `ship` writes the dated CHANGELOG heading and never publishes; `scaffold` writes the release workflows and never publishes.
+argument-hint: "[dry-run | ship | scaffold]"
---
# `/abcd:launch` release preview and release cut
@@ -137,6 +137,45 @@ Exit codes, same shape as step 1:
Then show the user the written heading and the diff, so a human reviews the release
record before it is committed. This command never commits, tags, or publishes.
+## Scaffold — the release-gate scaffolder
+
+`scaffold` writes the changelog-driven release machinery into a **managed repo that
+lacks it** — a different job from `ship` (which cuts a release in a repo that
+already has the machinery). It **never publishes**.
+
+```bash
+abcd launch scaffold --json
+```
+
+It writes three files, wired to the repo's own default branch and Go version:
+
+- `.github/workflows/release.yml` — verify → build → publish, the verify gate
+ armed against the reviewed **content** commit (`HEAD^2^` on the auto-release
+ merge path, `HEAD^` on a direct tag), so the first public release cannot hit the
+ receipt-vs-tag self-reference.
+- `.github/workflows/auto-release.yml` — newest dated CHANGELOG heading → tag that
+ commit → call `release.yml`. `GITHUB_TOKEN`-only, no personal access token.
+- `.abcd/development/release-gate/README.md` — the adr-37 runbook.
+
+The workflows come from one embedded template that abcd-cli's own release
+workflows are regenerated from (self-scaffold parity), so every abcd release
+exercises the exact machinery a managed repo receives. The scaffolded `release.yml`
+carries a `workflow_dispatch` **rehearsal**: run it green once before the first
+real release — it arms the full gate against a simulated changelog roll and
+reviewed-content commit, proves the gate admits, and publishes nothing (no tag,
+Release, or attestation).
+
+It is idempotent and fail-safe. Exit codes gate the flow:
+
+- **0** — every file written, or already current (a no-op re-run). Report the
+ per-file disposition.
+- **1** — a file exists and **differs** (hand-edited or stale); the report names
+ it and **nothing was written**. Relay it; re-run with `--confirm` to overwrite.
+- **2** — a structural fault (the repository or a template could not be read).
+
+A refusal is a result to relay, not a crash. Never hand-edit the workflows to work
+around it: re-run with `--confirm` when the operator intends to replace the drift.
+
If the `abcd` binary is not on `PATH`, fall back to
`go run ./cmd/abcd launch ...` from the repo root, or tell the user to build it
with `make build`.
diff --git a/docs/reference/cli/commands.md b/docs/reference/cli/commands.md
index 0966c2e3..43090109 100644
--- a/docs/reference/cli/commands.md
+++ b/docs/reference/cli/commands.md
@@ -351,6 +351,18 @@ Preview the public launch bundle and release gates (read-only)
--dry-run preview the launch bundle and gates without publishing
```
+#### `abcd launch scaffold`
+
+Scaffold the changelog-driven release gate (release.yml, auto-release.yml, runbook) into this repo
+
+**Usage:** `abcd launch scaffold [--confirm] [flags]`
+
+**Flags:**
+
+```
+ --confirm overwrite a hand-edited scaffolded file with the current machinery
+```
+
#### `abcd launch ship`
Cut a release: derive the version and the record set from what shipped (exit 1 when the cut refuses)
diff --git a/internal/core/launch/scaffold/render.go b/internal/core/launch/scaffold/render.go
new file mode 100644
index 00000000..038cab42
--- /dev/null
+++ b/internal/core/launch/scaffold/render.go
@@ -0,0 +1,122 @@
+// Package scaffold renders and writes the changelog-driven release machinery —
+// release.yml, auto-release.yml, and the adr-37 runbook — into a managed repo
+// that lacks it (itd-93, spc-14).
+//
+// # Self-scaffold parity (one template, one artifact)
+//
+// The three files ship as embedded templates. abcd-cli's OWN release workflows
+// are regenerated from these same templates with the abcd fact set
+// (AbcdSubstitutions): TestSelfScaffoldParity asserts the rendered output is
+// byte-identical to the committed .github/workflows/*.yml, so the proven pattern
+// and the shipped template can never drift — every abcd release exercises the
+// exact machinery a managed repo receives.
+//
+// A bare managed repo passes a degraded fact set (BareSubstitutions): no semantic
+// detectors, a generic Go build, and only the deterministic verify gates. The
+// template's abcd-specific regions are guarded so the bare rendering omits them
+// cleanly and still parses, arms GITHUB_TOKEN-only, and injects nothing.
+//
+// # Why custom template delimiters
+//
+// A GitHub Actions workflow is dense with `${{ … }}` expressions, which collide
+// with text/template's default `{{ … }}`. The templates therefore use `<%` / `%>`
+// (which never appear in a workflow) as delimiters, so every `${{ … }}` passes
+// through as literal text and only the scaffold's own directives are evaluated.
+package scaffold
+
+import (
+ "bytes"
+ "embed"
+ "fmt"
+ "text/template"
+)
+
+//go:embed templates/release.yml.tmpl templates/auto-release.yml.tmpl templates/runbook.md.tmpl
+var templatesFS embed.FS
+
+// Gate is one named verify-job step: a display name and the shell it runs. The
+// deterministic gates a managed repo inherits beyond the generic Go leg are
+// expressed as data so the runbook's numbered list and the workflow's steps are
+// rendered from one source (the gate_lockstep invariant, in-template).
+type Gate struct {
+ Name string
+ Run string
+}
+
+// Substitutions is the fact set a rendering binds against. AbcdSubstitutions
+// reproduces abcd-cli's live workflows byte-for-byte; BareSubstitutions degrades
+// to the deterministic gates alone.
+type Substitutions struct {
+ // DefaultBranch is the branch auto-release triggers on and the no-branch-commit
+ // tripwire guards — derived from the target repo, never hard-coded.
+ DefaultBranch string
+ // GoVersion is the setup-go minor version (e.g. "1.25").
+ GoVersion string
+ // Abcd selects abcd-cli's full rendering: the extra deterministic verify gates,
+ // the semantic release gate, and the four-binary cross-compile + attest build.
+ // A bare managed repo sets it false and gets a generic Go build with no semantic
+ // detectors.
+ Abcd bool
+ // ExtraGates are the deterministic verify steps beyond the generic Go leg
+ // (gofmt/build/vet/test/race). Rendered into both the workflow's verify job and
+ // the runbook's numbered gate list, so the two stay in lockstep by construction.
+ ExtraGates []Gate
+ // SemanticGates are the required host-run detectors the release gate arms
+ // (`--require-gate `). Empty means no semantic detector is configured and
+ // the deterministic gates alone admit the release (spc-14 clean degradation).
+ SemanticGates []string
+}
+
+// Rendered is the file set a scaffold run produces, keyed by repo-relative path.
+type Rendered struct {
+ ReleaseYML []byte
+ AutoReleaseYML []byte
+ Runbook []byte
+}
+
+// Repo-relative destinations the scaffold writes. Fixed by the GitHub Actions
+// discovery rule (workflows) and mirrored from abcd-cli's own layout (runbook).
+const (
+ ReleaseYMLPath = ".github/workflows/release.yml"
+ AutoReleaseYMLPath = ".github/workflows/auto-release.yml"
+ RunbookPath = ".abcd/development/release-gate/README.md"
+)
+
+// Render binds the three templates against subs and returns their bytes. A
+// template parse or execute fault is a programming error in the embedded
+// templates, surfaced as an error rather than a panic.
+func Render(subs Substitutions) (Rendered, error) {
+ rel, err := renderOne("templates/release.yml.tmpl", subs)
+ if err != nil {
+ return Rendered{}, err
+ }
+ auto, err := renderOne("templates/auto-release.yml.tmpl", subs)
+ if err != nil {
+ return Rendered{}, err
+ }
+ book, err := renderOne("templates/runbook.md.tmpl", subs)
+ if err != nil {
+ return Rendered{}, err
+ }
+ return Rendered{ReleaseYML: rel, AutoReleaseYML: auto, Runbook: book}, nil
+}
+
+// renderOne parses and executes a single embedded template with the `<%`/`%>`
+// delimiters.
+func renderOne(name string, subs Substitutions) ([]byte, error) {
+ raw, err := templatesFS.ReadFile(name)
+ if err != nil {
+ return nil, fmt.Errorf("scaffold: read embedded template %s: %w", name, err)
+ }
+ tmpl, err := template.New(name).Delims("<%", "%>").
+ Funcs(template.FuncMap{"add": func(a, b int) int { return a + b }}).
+ Option("missingkey=error").Parse(string(raw))
+ if err != nil {
+ return nil, fmt.Errorf("scaffold: parse template %s: %w", name, err)
+ }
+ var buf bytes.Buffer
+ if err := tmpl.Execute(&buf, subs); err != nil {
+ return nil, fmt.Errorf("scaffold: execute template %s: %w", name, err)
+ }
+ return buf.Bytes(), nil
+}
diff --git a/internal/core/launch/scaffold/scaffold.go b/internal/core/launch/scaffold/scaffold.go
new file mode 100644
index 00000000..943c7d6b
--- /dev/null
+++ b/internal/core/launch/scaffold/scaffold.go
@@ -0,0 +1,287 @@
+package scaffold
+
+import (
+ "errors"
+ "fmt"
+ "os"
+ "path/filepath"
+ "regexp"
+ "strings"
+
+ "github.com/REPPL/abcd-cli/internal/fsutil"
+)
+
+// maxWorkflowBytes caps a guarded read of an existing scaffolded file. A
+// workflow or runbook is a short text file; a larger one is not one we wrote, and
+// the drift check refuses it rather than streaming it unbounded.
+const maxWorkflowBytes = 1 << 20
+
+// ErrScaffoldBlocked is returned when a scaffold run refuses because an existing
+// file differs from the machinery and --confirm was not given.
+var ErrScaffoldBlocked = errors.New("scaffold refused: an existing file was hand-edited (pass --confirm to overwrite)")
+
+// FileStatus is one scaffolded file's disposition in the report. Every value
+// means what it says on disk: "written" is written, never merely planned.
+type FileStatus string
+
+const (
+ // StatusWritten — the file was actually written (created, or overwritten under
+ // confirm). Only set AFTER the write succeeds.
+ StatusWritten FileStatus = "written"
+ // StatusCurrent — the file already matches the machinery; nothing was written.
+ StatusCurrent FileStatus = "current"
+ // StatusRefused — the file exists and differs, and --confirm was not given, so
+ // it was left untouched.
+ StatusRefused FileStatus = "refused"
+ // StatusSkipped — the file WOULD have been written, but the run refused (a
+ // sibling was hand-edited) or a write faulted first, so it was NOT written. The
+ // scaffold is all-or-nothing, so this reports honestly that nothing landed.
+ StatusSkipped FileStatus = "skipped"
+)
+
+// disposition is the pre-write classification of a target: what it is on disk,
+// independent of --confirm. It never claims a write happened.
+type disposition int
+
+const (
+ dispAbsent disposition = iota // no file → will be created
+ dispCurrent // byte-identical to the machinery → no-op
+ dispDiffers // present and different → refuse or (with --confirm) overwrite
+)
+
+// FileOutcome is the per-file result of a scaffold run.
+type FileOutcome struct {
+ Path string `json:"path"`
+ Status FileStatus `json:"status"`
+ // Detail explains a refusal (why the file was left alone).
+ Detail string `json:"detail,omitempty"`
+}
+
+// Report is the outcome of a scaffold run.
+type Report struct {
+ Substitutions Substitutions `json:"-"`
+ DefaultBranch string `json:"default_branch"`
+ GoVersion string `json:"go_version"`
+ Files []FileOutcome `json:"files"`
+ Wrote int `json:"wrote"`
+ Refused int `json:"refused"`
+ // NoOp is true when every file was already current (the idempotent re-run).
+ NoOp bool `json:"no_op"`
+}
+
+// Request is the input to a scaffold run.
+type Request struct {
+ RepoRoot string
+ // Confirm overwrites a file that exists and differs from the machinery (the
+ // transparent-confirm on a hand-edited workflow). Absent, such a file is
+ // refused and left untouched, and the run reports ErrScaffoldBlocked.
+ Confirm bool
+}
+
+// Scaffold writes the changelog-driven release machinery into RepoRoot: a
+// generic (bare-repo) release.yml, auto-release.yml, and the adr-37 runbook, each
+// wired to the repo's own default branch and Go version. It is idempotent and
+// fail-safe:
+//
+// - a file absent on disk is written;
+// - a file byte-identical to the machinery is a no-op (StatusCurrent);
+// - a file that exists and DIFFERS (hand-edited, or a stale scaffold) is REFUSED
+// and left untouched unless Confirm is set, in which case it is overwritten.
+//
+// A run that refuses any file returns ErrScaffoldBlocked with the report, so the
+// caller can render exactly what was and was not touched — no partial half-write.
+func Scaffold(req Request) (Report, error) {
+ branch, goVersion := DeriveRepoFacts(req.RepoRoot)
+ subs := BareSubstitutions(branch, goVersion)
+ rendered, err := Render(subs)
+ if err != nil {
+ return Report{}, err
+ }
+
+ report := Report{Substitutions: subs, DefaultBranch: branch, GoVersion: goVersion}
+ planned := []struct {
+ rel string
+ data []byte
+ }{
+ {ReleaseYMLPath, rendered.ReleaseYML},
+ {AutoReleaseYMLPath, rendered.AutoReleaseYML},
+ {RunbookPath, rendered.Runbook},
+ }
+
+ // First pass: classify every file WITHOUT writing. A refusal on any file with
+ // Confirm unset aborts the whole run before a single write, so the scaffold is
+ // all-or-nothing rather than half-applied. Nothing is marked "written" here —
+ // a planned write is only tentative until the second pass commits it, so the
+ // report never claims a file landed that did not (the StatusWritten contract).
+ outcomes := make([]FileOutcome, len(planned))
+ writeNeeded := make([]bool, len(planned))
+ overwrite := make([]bool, len(planned))
+ refused := 0
+ for i, p := range planned {
+ abs := filepath.Join(req.RepoRoot, filepath.FromSlash(p.rel))
+ disp, detail := classify(abs, p.data)
+ switch disp {
+ case dispCurrent:
+ outcomes[i] = FileOutcome{Path: p.rel, Status: StatusCurrent}
+ case dispAbsent:
+ writeNeeded[i] = true
+ outcomes[i] = FileOutcome{Path: p.rel, Status: StatusSkipped} // provisional until written
+ case dispDiffers:
+ if req.Confirm {
+ writeNeeded[i] = true
+ overwrite[i] = true
+ outcomes[i] = FileOutcome{Path: p.rel, Status: StatusSkipped} // provisional until written
+ } else {
+ refused++
+ outcomes[i] = FileOutcome{Path: p.rel, Status: StatusRefused, Detail: detail}
+ }
+ }
+ }
+
+ if refused > 0 {
+ // Abort all-or-nothing: nothing is written. Every file that WOULD have been
+ // written stays StatusSkipped with a reason, so the per-file report and the
+ // wrote/refused counters match what is actually on disk.
+ for i := range outcomes {
+ if writeNeeded[i] {
+ outcomes[i].Detail = "not written: the run refused because another file was hand-edited (all-or-nothing)"
+ }
+ }
+ report.Files = outcomes
+ report.Refused = refused
+ return report, ErrScaffoldBlocked
+ }
+
+ // Second pass: commit the writes. Every file that reaches here is either a
+ // create or a confirmed overwrite; a file becomes StatusWritten only once its
+ // write actually succeeds.
+ wrote := 0
+ for i, p := range planned {
+ if !writeNeeded[i] {
+ continue
+ }
+ abs := filepath.Join(req.RepoRoot, filepath.FromSlash(p.rel))
+ if err := fsutil.WriteFileAtomicPreserveMode(abs, p.data); err != nil {
+ // The atomic writer never leaves a half-written file; the files written
+ // before this fault are marked written, this one and any later planned
+ // file stay StatusSkipped (not written), so the report matches disk.
+ outcomes[i].Detail = "not written: a write faulted on this file"
+ report.Files = outcomes
+ report.Wrote = wrote
+ return report, fmt.Errorf("scaffold: write %s: %w", p.rel, err)
+ }
+ outcomes[i].Status = StatusWritten
+ if overwrite[i] {
+ outcomes[i].Detail = "overwritten under --confirm"
+ }
+ wrote++
+ }
+
+ report.Files = outcomes
+ report.Wrote = wrote
+ report.NoOp = wrote == 0
+ return report, nil
+}
+
+// classify reports whether abs is absent (→ dispAbsent, write it), byte-equal to
+// want (→ dispCurrent, no-op), or present-and-different (→ dispDiffers, refuse or
+// overwrite under --confirm). A non-regular leaf (symlink/FIFO/device) or an
+// unreadable existing file is treated as dispDiffers — the scaffold never writes
+// through a symlink silently and never assumes an unreadable file is safe to
+// clobber. The string is a refusal reason, set only for dispDiffers.
+func classify(abs string, want []byte) (disposition, string) {
+ got, err := fsutil.ReadGuarded(abs, maxWorkflowBytes)
+ if err != nil {
+ if os.IsNotExist(err) {
+ return dispAbsent, ""
+ }
+ if errors.Is(err, fsutil.ErrNotRegular) {
+ return dispDiffers, "existing path is not a regular file (a symlink or non-regular leaf is never written through)"
+ }
+ if errors.Is(err, fsutil.ErrTooBig) {
+ return dispDiffers, "existing file exceeds the size cap; this is not machinery abcd wrote"
+ }
+ return dispDiffers, "existing file is unreadable: " + err.Error()
+ }
+ if string(got) == string(want) {
+ return dispCurrent, ""
+ }
+ return dispDiffers, "existing file differs from the current machinery (hand-edited or stale)"
+}
+
+// goVersionRe matches a strict major.minor Go version — the only shape allowed
+// into the rendered `go-version:` value, so a crafted go.mod cannot inject YAML.
+var goVersionRe = regexp.MustCompile(`^[0-9]+\.[0-9]+$`)
+
+// goModVersionRe extracts the `go X.Y[.Z]` directive from go.mod.
+var goModVersionRe = regexp.MustCompile(`(?m)^go[ \t]+([0-9]+\.[0-9]+)(?:\.[0-9]+)?`)
+
+// branchNameRe is the injection-safe allowlist for a default-branch name written
+// into the workflow YAML: git ref characters only, no whitespace or YAML
+// metacharacters. A name outside it falls back to the safe default.
+var branchNameRe = regexp.MustCompile(`^[A-Za-z0-9._/-]+$`)
+
+// defaultGoVersion is the fallback when go.mod cannot be read or its version is
+// not a clean major.minor.
+const defaultGoVersion = "1.25"
+
+// defaultBranch is the fallback default branch when the repo does not name one.
+const defaultBranch = "main"
+
+// DeriveRepoFacts reads the target repo's own default branch and Go version, both
+// validated against an injection-safe allowlist before they can reach the
+// rendered YAML. Anything malformed or absent falls back to a safe default, so a
+// hostile go.mod or ref name can never inject workflow content.
+func DeriveRepoFacts(repoRoot string) (branch, goVersion string) {
+ return deriveBranch(repoRoot), deriveGoVersion(repoRoot)
+}
+
+func deriveGoVersion(repoRoot string) string {
+ data, err := fsutil.ReadGuarded(filepath.Join(repoRoot, "go.mod"), maxWorkflowBytes)
+ if err != nil {
+ return defaultGoVersion
+ }
+ m := goModVersionRe.FindSubmatch(data)
+ if m == nil || !goVersionRe.Match(m[1]) {
+ return defaultGoVersion
+ }
+ return string(m[1])
+}
+
+// deriveBranch resolves the repo's default branch from its committed git config
+// (the symref origin/HEAD points at, or the checked-out HEAD), validated to the
+// injection-safe allowlist. It reads git's plaintext refs directly rather than
+// shelling out, so it stays dependency-free and works on a bare checkout.
+func deriveBranch(repoRoot string) string {
+ gitDir := filepath.Join(repoRoot, ".git")
+ // origin/HEAD, when set, names the remote default branch: a line
+ // "ref: refs/remotes/origin/" in .git/refs/remotes/origin/HEAD.
+ if data, err := os.ReadFile(filepath.Join(gitDir, "refs", "remotes", "origin", "HEAD")); err == nil {
+ if b := branchFromSymref(string(data), "refs/remotes/origin/"); b != "" {
+ return b
+ }
+ }
+ // Fall back to the checked-out branch (.git/HEAD → "ref: refs/heads/").
+ if data, err := os.ReadFile(filepath.Join(gitDir, "HEAD")); err == nil {
+ if b := branchFromSymref(string(data), "refs/heads/"); b != "" {
+ return b
+ }
+ }
+ return defaultBranch
+}
+
+// branchFromSymref extracts and validates a branch name from a "ref: "
+// symbolic-ref line. An unvalidated name yields "" so the caller falls back.
+func branchFromSymref(content, prefix string) string {
+ line := strings.TrimSpace(content)
+ line = strings.TrimPrefix(line, "ref:")
+ line = strings.TrimSpace(line)
+ if !strings.HasPrefix(line, prefix) {
+ return ""
+ }
+ name := strings.TrimPrefix(line, prefix)
+ if name == "" || !branchNameRe.MatchString(name) {
+ return ""
+ }
+ return name
+}
diff --git a/internal/core/launch/scaffold/scaffold_test.go b/internal/core/launch/scaffold/scaffold_test.go
new file mode 100644
index 00000000..ccd9e6b1
--- /dev/null
+++ b/internal/core/launch/scaffold/scaffold_test.go
@@ -0,0 +1,455 @@
+package scaffold
+
+import (
+ "os"
+ "os/exec"
+ "path/filepath"
+ "runtime"
+ "strings"
+ "testing"
+
+ "github.com/REPPL/abcd-cli/internal/gittest"
+)
+
+// repoRoot returns the abcd-cli repo root from this test file's on-disk location
+// (internal/core/launch/scaffold → four levels up), so the parity test runs
+// against the committed .github/workflows rather than a fixture.
+func repoRoot(t *testing.T) string {
+ t.Helper()
+ _, file, _, ok := runtime.Caller(0)
+ if !ok {
+ t.Fatal("runtime.Caller failed")
+ }
+ return filepath.Clean(filepath.Join(filepath.Dir(file), "..", "..", "..", ".."))
+}
+
+// TestSelfScaffoldParity is the load-bearing decision-(b) test: rendering the
+// shipped templates with abcd-cli's own fact set must reproduce the committed
+// release.yml and auto-release.yml BYTE-FOR-BYTE. If they ever drift, the proven
+// pattern and the template are no longer one artifact and this fails — so every
+// abcd release exercises exactly the machinery a managed repo receives.
+func TestSelfScaffoldParity(t *testing.T) {
+ root := repoRoot(t)
+ rendered, err := Render(AbcdSubstitutions())
+ if err != nil {
+ t.Fatalf("render abcd profile: %v", err)
+ }
+ cases := []struct {
+ name string
+ rel string
+ got []byte
+ }{
+ {"release.yml", ReleaseYMLPath, rendered.ReleaseYML},
+ {"auto-release.yml", AutoReleaseYMLPath, rendered.AutoReleaseYML},
+ }
+ for _, c := range cases {
+ want, err := os.ReadFile(filepath.Join(root, filepath.FromSlash(c.rel)))
+ if err != nil {
+ t.Fatalf("read committed %s: %v", c.rel, err)
+ }
+ if string(c.got) != string(want) {
+ t.Errorf("%s: abcd rendering is not byte-identical to the committed workflow.\n%s",
+ c.name, firstDiff(string(want), string(c.got)))
+ }
+ }
+}
+
+// firstDiff returns a short human pointer to the first differing line so a parity
+// failure is diagnosable without dumping the whole file.
+func firstDiff(want, got string) string {
+ wl, gl := strings.Split(want, "\n"), strings.Split(got, "\n")
+ for i := 0; i < len(wl) || i < len(gl); i++ {
+ var w, g string
+ if i < len(wl) {
+ w = wl[i]
+ }
+ if i < len(gl) {
+ g = gl[i]
+ }
+ if w != g {
+ return "first diff at line " + itoa(i+1) + ":\n committed: " + w + "\n rendered: " + g
+ }
+ }
+ return "(no line diff; trailing bytes differ)"
+}
+
+func itoa(n int) string {
+ if n == 0 {
+ return "0"
+ }
+ var b [20]byte
+ i := len(b)
+ for n > 0 {
+ i--
+ b[i] = byte('0' + n%10)
+ n /= 10
+ }
+ return string(b[i:])
+}
+
+// TestBareRenderOmitsAbcdMachinery proves the degraded rendering a managed repo
+// with no semantic detector receives: the deterministic Go gates alone, a generic
+// build, no semantic gate, and no abcd-specific step — yet still a valid, gated
+// workflow with the rehearsal.
+func TestBareRenderOmitsAbcdMachinery(t *testing.T) {
+ rendered, err := Render(BareSubstitutions("trunk", "1.23"))
+ if err != nil {
+ t.Fatalf("render bare profile: %v", err)
+ }
+ rel := string(rendered.ReleaseYML)
+
+ // The abcd-specific detectors and steps must be gone.
+ for _, needle := range []string{
+ "record-lint", "docs-currency-reviewer", "iss35-brief-surface-crosscheck",
+ "check-reviews.sh", "make smoke", "make build", "abcd docs lint",
+ "semantic-release-gate", "Cross-compile the four binaries",
+ } {
+ if strings.Contains(rel, needle) {
+ t.Errorf("bare release.yml must not contain abcd-specific %q", needle)
+ }
+ }
+ // The generic build + deterministic gates + rehearsal must be present.
+ for _, needle := range []string{
+ "go build ./...", "go-version: '1.23'", "gofmt -l .",
+ "workflow_dispatch:", "rehearsal:", "gh release create",
+ } {
+ if !strings.Contains(rel, needle) {
+ t.Errorf("bare release.yml must contain %q", needle)
+ }
+ }
+ // auto-release must key on the repo's own default branch, not a hard-coded main.
+ if !strings.Contains(string(rendered.AutoReleaseYML), "branches: [trunk]") {
+ t.Error("bare auto-release.yml must wire the repo's own default branch")
+ }
+ // The bare runbook must say no semantic detector is configured (AC3).
+ if !strings.Contains(string(rendered.Runbook), "No semantic detector is configured") {
+ t.Error("bare runbook must state that no semantic detector is configured")
+ }
+}
+
+// TestBareRunbookGateListMatchesWorkflow is the in-template gate_lockstep
+// property: the runbook's numbered deterministic-gate list must be exactly the
+// generic five when no extra gate is configured, so a managed repo's own lockstep
+// check stays green.
+func TestBareRunbookGateListMatchesWorkflow(t *testing.T) {
+ rendered, err := Render(BareSubstitutions("main", "1.25"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ book := string(rendered.Runbook)
+ for _, g := range []string{"1. Format (gofmt)", "5. Test (race, internal)"} {
+ if !strings.Contains(book, g) {
+ t.Errorf("bare runbook must list deterministic gate %q", g)
+ }
+ }
+ if strings.Contains(book, "6. ") {
+ t.Error("bare runbook must not number a sixth gate (no extra gates configured)")
+ }
+}
+
+// TestAbcdRunbookNumbersExtraGates proves the abcd runbook's numbered list is
+// rendered from the same ExtraGates source as the workflow steps (the in-template
+// gate_lockstep invariant), continuing 6..9.
+func TestAbcdRunbookNumbersExtraGates(t *testing.T) {
+ rendered, err := Render(AbcdSubstitutions())
+ if err != nil {
+ t.Fatal(err)
+ }
+ book := string(rendered.Runbook)
+ for _, want := range []string{
+ "6. Record-lint (design-record drift gate)",
+ "7. Docs-lint (docs-currency gate)",
+ "8. Reviews-charter discipline (RD001-RD003)",
+ "9. Smoke every command (self-discovering harness)",
+ } {
+ if !strings.Contains(book, want) {
+ t.Errorf("abcd runbook must number extra gate %q", want)
+ }
+ }
+}
+
+// TestGeneratedYAMLIsGithubTokenOnly proves both profiles run on the built-in
+// token alone — no personal access token, no standing secret beyond GITHUB_TOKEN.
+func TestGeneratedYAMLIsGithubTokenOnly(t *testing.T) {
+ for _, subs := range []Substitutions{AbcdSubstitutions(), BareSubstitutions("main", "1.25")} {
+ rendered, err := Render(subs)
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, doc := range [][]byte{rendered.ReleaseYML, rendered.AutoReleaseYML} {
+ s := string(doc)
+ // The only secret referenced is GITHUB_TOKEN / github.token.
+ for _, ref := range secretRefs(s) {
+ if ref != "secrets.GITHUB_TOKEN" && ref != "github.token" {
+ t.Errorf("workflow references a non-GITHUB_TOKEN secret %q (Abcd=%v)", ref, subs.Abcd)
+ }
+ }
+ if strings.Contains(strings.ToUpper(s), "PERSONAL_ACCESS_TOKEN") || strings.Contains(s, "secrets.PAT") {
+ t.Errorf("workflow references a personal access token (Abcd=%v)", subs.Abcd)
+ }
+ }
+ }
+}
+
+// secretRefs returns every `secrets.X` and `github.token` reference in a workflow.
+func secretRefs(s string) []string {
+ var out []string
+ for _, tok := range []string{"secrets.", "github.token"} {
+ i := 0
+ for {
+ j := strings.Index(s[i:], tok)
+ if j < 0 {
+ break
+ }
+ start := i + j
+ if tok == "github.token" {
+ out = append(out, "github.token")
+ i = start + len(tok)
+ continue
+ }
+ // read secrets.
+ k := start + len(tok)
+ end := k
+ for end < len(s) && (isIdent(s[end])) {
+ end++
+ }
+ out = append(out, "secrets."+s[k:end])
+ i = end
+ }
+ }
+ return out
+}
+
+func isIdent(b byte) bool {
+ return b == '_' || (b >= 'A' && b <= 'Z') || (b >= 'a' && b <= 'z') || (b >= '0' && b <= '9')
+}
+
+// TestRehearsalPublishesNothing is the AC6 property: the rehearsal job is gated to
+// workflow_dispatch, holds contents: read only, and contains no publish verb — no
+// Release creation, tag push, or attestation. Both profiles.
+func TestRehearsalPublishesNothing(t *testing.T) {
+ for _, subs := range []Substitutions{AbcdSubstitutions(), BareSubstitutions("main", "1.25")} {
+ rendered, err := Render(subs)
+ if err != nil {
+ t.Fatal(err)
+ }
+ job := rehearsalJob(t, string(rendered.ReleaseYML))
+ if !strings.Contains(job, "if: github.event_name == 'workflow_dispatch'") {
+ t.Errorf("rehearsal must be gated to workflow_dispatch (Abcd=%v)", subs.Abcd)
+ }
+ if !strings.Contains(job, "permissions:\n contents: read") {
+ t.Errorf("rehearsal must hold contents: read only, so it cannot publish (Abcd=%v)", subs.Abcd)
+ }
+ for _, forbidden := range []string{
+ "gh release create", "gh release upload", "git push", "git tag",
+ "actions/attest", "contents: write",
+ } {
+ if strings.Contains(job, forbidden) {
+ t.Errorf("rehearsal must not contain publish verb %q (Abcd=%v)", forbidden, subs.Abcd)
+ }
+ }
+ }
+}
+
+// rehearsalJob slices the `rehearsal:` job out of the workflow (from its key to
+// EOF, since it is the last job).
+func rehearsalJob(t *testing.T, workflow string) string {
+ t.Helper()
+ idx := strings.Index(workflow, "\n rehearsal:\n")
+ if idx < 0 {
+ t.Fatal("no rehearsal job found in rendered release.yml")
+ }
+ return workflow[idx:]
+}
+
+// TestReleaseJobGatedOffRehearsal proves the real publish job never runs on a
+// manual rehearsal dispatch — the guard that makes the rehearsal publish nothing.
+func TestReleaseJobGatedOffRehearsal(t *testing.T) {
+ rendered, err := Render(AbcdSubstitutions())
+ if err != nil {
+ t.Fatal(err)
+ }
+ s := string(rendered.ReleaseYML)
+ relIdx := strings.Index(s, "\n release:\n")
+ rehIdx := strings.Index(s, "\n rehearsal:\n")
+ if relIdx < 0 || rehIdx < 0 {
+ t.Fatal("release/rehearsal jobs not found")
+ }
+ releaseJob := s[relIdx:rehIdx]
+ if !strings.Contains(releaseJob, "if: github.event_name != 'workflow_dispatch'") {
+ t.Error("the release (publish) job must be gated off workflow_dispatch so a rehearsal publishes nothing")
+ }
+}
+
+// TestScaffoldIdempotentAndRefusesHandEdit exercises the AC4 write path against a
+// throwaway git repo: first run writes; a re-run is a no-op; a hand-edit is
+// refused and left untouched; --confirm overwrites.
+func TestScaffoldIdempotentAndRefusesHandEdit(t *testing.T) {
+ dir := t.TempDir()
+ mustWrite(t, filepath.Join(dir, "go.mod"), "module example.com/x\n\ngo 1.22\n")
+ gitInit(t, dir, "release-line")
+
+ // First run: three files written.
+ rep, err := Scaffold(Request{RepoRoot: dir})
+ if err != nil {
+ t.Fatalf("first scaffold: %v", err)
+ }
+ if rep.Wrote != 3 || rep.NoOp {
+ t.Fatalf("first run should write 3 files, got wrote=%d noop=%v", rep.Wrote, rep.NoOp)
+ }
+ // Wired to the repo's own facts.
+ if rep.DefaultBranch != "release-line" || rep.GoVersion != "1.22" {
+ t.Errorf("expected derived branch=release-line go=1.22, got %s / %s", rep.DefaultBranch, rep.GoVersion)
+ }
+ relPath := filepath.Join(dir, filepath.FromSlash(ReleaseYMLPath))
+ autoPath := filepath.Join(dir, filepath.FromSlash(AutoReleaseYMLPath))
+ if !contains(t, autoPath, "branches: [release-line]") {
+ t.Error("auto-release.yml must be wired to the derived default branch")
+ }
+
+ // Re-run: pure no-op, nothing written.
+ rep2, err := Scaffold(Request{RepoRoot: dir})
+ if err != nil {
+ t.Fatalf("re-run: %v", err)
+ }
+ if rep2.Wrote != 0 || !rep2.NoOp {
+ t.Fatalf("re-run should be a no-op, got wrote=%d noop=%v", rep2.Wrote, rep2.NoOp)
+ }
+
+ // Hand-edit a scaffolded file, then re-run: it must refuse and leave the edit.
+ edited := "# hand-edited by the operator\n"
+ mustWrite(t, relPath, edited)
+ rep3, err := Scaffold(Request{RepoRoot: dir})
+ if err == nil {
+ t.Fatal("scaffold over a hand-edit must return ErrScaffoldBlocked")
+ }
+ if rep3.Refused == 0 {
+ t.Error("the hand-edited file must be refused")
+ }
+ if got := readFile(t, relPath); got != edited {
+ t.Error("a refused file must be left byte-for-byte untouched")
+ }
+ // The OTHER files must not have been rewritten either (all-or-nothing).
+ if rep3.Wrote != 0 {
+ t.Errorf("a refusal must abort before any write, got wrote=%d", rep3.Wrote)
+ }
+
+ // --confirm overwrites the hand-edit with the machinery.
+ rep4, err := Scaffold(Request{RepoRoot: dir, Confirm: true})
+ if err != nil {
+ t.Fatalf("confirm run: %v", err)
+ }
+ if rep4.Wrote != 1 {
+ t.Errorf("confirm should rewrite exactly the drifted file, got wrote=%d", rep4.Wrote)
+ }
+ if contains(t, relPath, "hand-edited") {
+ t.Error("--confirm must overwrite the hand-edit with the machinery")
+ }
+}
+
+// TestRefusalAbortReportMatchesDisk pins the report-vs-disk contract on the
+// all-or-nothing abort path: when one file is hand-edited (refused) and another
+// is still absent, the run writes NOTHING and the report must not claim the absent
+// file was written — it is reported as skipped and remains absent on disk.
+func TestRefusalAbortReportMatchesDisk(t *testing.T) {
+ dir := t.TempDir()
+ mustWrite(t, filepath.Join(dir, "go.mod"), "module example.com/x\n\ngo 1.22\n")
+ gitInit(t, dir, "main")
+
+ // auto-release.yml exists and differs (a hand-edit); release.yml is absent.
+ autoPath := filepath.Join(dir, filepath.FromSlash(AutoReleaseYMLPath))
+ relPath := filepath.Join(dir, filepath.FromSlash(ReleaseYMLPath))
+ mustWrite(t, autoPath, "# hand-edited auto-release\n")
+
+ rep, err := Scaffold(Request{RepoRoot: dir})
+ if err == nil {
+ t.Fatal("a hand-edited sibling must abort the run with ErrScaffoldBlocked")
+ }
+ if rep.Wrote != 0 {
+ t.Errorf("nothing must be written on the abort path, got wrote=%d", rep.Wrote)
+ }
+ // The absent file must NOT be reported as written, and must still be absent.
+ for _, f := range rep.Files {
+ if f.Path == ReleaseYMLPath {
+ if f.Status == StatusWritten {
+ t.Errorf("release.yml was NOT written but the report says %q", f.Status)
+ }
+ if f.Status != StatusSkipped {
+ t.Errorf("an unwritten planned file must report as %q, got %q", StatusSkipped, f.Status)
+ }
+ }
+ }
+ if _, statErr := os.Stat(relPath); !os.IsNotExist(statErr) {
+ t.Error("release.yml must remain absent on disk after an aborted run")
+ }
+}
+
+// TestDeriveRepoFactsRejectsHostileInputs proves the substitution inputs are
+// sanitised before they can reach the YAML — a crafted go.mod or ref name falls
+// back to the safe default rather than injecting workflow content.
+func TestDeriveRepoFactsRejectsHostileInputs(t *testing.T) {
+ dir := t.TempDir()
+ // A go.mod whose version carries a YAML-breaking payload.
+ mustWrite(t, filepath.Join(dir, "go.mod"), "module x\n\ngo 1.25'\ninjected: true\n")
+ if _, gv := DeriveRepoFacts(dir); gv != "1.25" {
+ // go 1.25 is a clean prefix match; ensure only the clean major.minor is taken.
+ if gv != "1.25" {
+ t.Errorf("go version must be sanitised to major.minor, got %q", gv)
+ }
+ }
+ // A hostile HEAD symref must not become the branch value.
+ gitDir := filepath.Join(dir, ".git")
+ mustMkdir(t, gitDir)
+ mustWrite(t, filepath.Join(gitDir, "HEAD"), "ref: refs/heads/main]\ninjected: [x\n")
+ if b, _ := DeriveRepoFacts(dir); b != "main" {
+ t.Errorf("a hostile branch name must fall back to %q, got %q", "main", b)
+ }
+}
+
+// --- helpers ---------------------------------------------------------------
+
+func mustWrite(t *testing.T, path, content string) {
+ t.Helper()
+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func mustMkdir(t *testing.T, path string) {
+ t.Helper()
+ if err := os.MkdirAll(path, 0o755); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func readFile(t *testing.T, path string) string {
+ t.Helper()
+ b, err := os.ReadFile(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return string(b)
+}
+
+func contains(t *testing.T, path, needle string) bool {
+ t.Helper()
+ return strings.Contains(readFile(t, path), needle)
+}
+
+// gitInit makes dir a git repo checked out on branchName, so deriveBranch reads a
+// real .git/HEAD symref.
+func gitInit(t *testing.T, dir, branchName string) {
+ t.Helper()
+ run := func(args ...string) {
+ cmd := exec.Command("git", args...)
+ cmd.Dir = dir
+ cmd.Env = gittest.Env(t) // hermetic git (iss-28): no user/system config leaks in
+ if out, err := cmd.CombinedOutput(); err != nil {
+ t.Fatalf("git %v: %v\n%s", args, err, out)
+ }
+ }
+ run("init", "-q", "-b", branchName)
+}
diff --git a/internal/core/launch/scaffold/substitutions.go b/internal/core/launch/scaffold/substitutions.go
new file mode 100644
index 00000000..464173b8
--- /dev/null
+++ b/internal/core/launch/scaffold/substitutions.go
@@ -0,0 +1,46 @@
+package scaffold
+
+// abcdSemanticGates is the required host-run detector list abcd-cli's release
+// gate arms (see .abcd/development/release-gate/README.md). It is the reference
+// semantic-gate set the self-scaffold parity rendering reproduces.
+var abcdSemanticGates = []string{"docs-currency-reviewer", "iss35-brief-surface-crosscheck"}
+
+// abcdExtraGates are the deterministic verify steps abcd-cli runs beyond the
+// generic Go leg (gofmt/build/vet/test/race). They render into both the verify
+// job and the runbook's numbered gate list from this one source, so the
+// gate_lockstep invariant (runbook list == workflow steps) holds by construction.
+var abcdExtraGates = []Gate{
+ {Name: "Record-lint (design-record drift gate)", Run: "go run ./cmd/record-lint"},
+ {Name: "Docs-lint (docs-currency gate)", Run: "go run ./cmd/abcd docs lint"},
+ {Name: "Reviews-charter discipline (RD001-RD003)", Run: "bash scripts/check-reviews.sh"},
+ {Name: "Smoke every command (self-discovering harness)", Run: "make smoke"},
+}
+
+// AbcdSubstitutions is abcd-cli's own fact set: the fixed inputs that regenerate
+// its live release.yml / auto-release.yml byte-for-byte (self-scaffold parity,
+// spc-14 decision b). It is deliberately hard-coded, not derived — it is the
+// reference rendering the parity test pins, and deriving it would let the pin
+// track the derivation rather than the proven workflow.
+func AbcdSubstitutions() Substitutions {
+ return Substitutions{
+ DefaultBranch: "main",
+ GoVersion: "1.25",
+ Abcd: true,
+ ExtraGates: abcdExtraGates,
+ SemanticGates: abcdSemanticGates,
+ }
+}
+
+// BareSubstitutions is the degraded fact set a managed repo with no semantic
+// detectors receives: the deterministic Go gates alone, a generic build, and no
+// host-run semantic gate (spc-14 clean degradation). DefaultBranch and GoVersion
+// are the repo's own facts, derived by the caller.
+func BareSubstitutions(defaultBranch, goVersion string) Substitutions {
+ return Substitutions{
+ DefaultBranch: defaultBranch,
+ GoVersion: goVersion,
+ Abcd: false,
+ ExtraGates: nil,
+ SemanticGates: nil,
+ }
+}
diff --git a/internal/core/launch/scaffold/templates/auto-release.yml.tmpl b/internal/core/launch/scaffold/templates/auto-release.yml.tmpl
new file mode 100644
index 00000000..1754245a
--- /dev/null
+++ b/internal/core/launch/scaffold/templates/auto-release.yml.tmpl
@@ -0,0 +1,183 @@
+name: auto-release
+
+# On every push to the default branch, tag-and-release the NEWEST dated
+# CHANGELOG version when it has no git tag yet — WITHOUT a PAT. A tag pushed with
+# the built-in GITHUB_TOKEN deliberately does NOT re-trigger release.yml's
+# tag-push event, so this workflow instead creates the tag and then invokes
+# release.yml directly as a reusable workflow (the `release` job below). That
+# keeps the whole publish path inside GitHub's own token model — no personal
+# access token, no elevated secret.
+#
+# Only the NEWEST dated version is ever tagged: older CHANGELOG versions are left
+# alone, because tagging them at the current HEAD would mis-point an immutable
+# tag at the wrong code. Idempotent: when the newest version is already tagged AND
+# its GitHub Release exists, `detect` reports need_tag=need_release=false and
+# nothing runs, so an ordinary (non-release) push to main does nothing. If the tag
+# exists but its Release is MISSING (e.g. a transient publish failure), `detect`
+# sets need_release=true and re-invokes `release` ALONE — built from the tagged
+# commit (release_ref), never the moved-on HEAD — so a flaky publish never
+# permanently wedges the version.
+on:
+ push:
+ branches: [<% .DefaultBranch %>]
+
+# Serialise: never let two auto-release runs race to create the same tag, and
+# never cancel one mid-flight — a half-created tag or release is worse than a
+# queued one.
+concurrency:
+ group: auto-release
+ cancel-in-progress: false
+
+# Floor for the workflow: read-only. Each job elevates itself to exactly the
+# scopes it needs, and no more.
+permissions:
+ contents: read
+
+jobs:
+ # Decide whether the newest dated CHANGELOG version still needs a tag. Pure
+ # read: no writes, no pushes, contents: read only.
+ detect:
+ runs-on: ubuntu-latest
+ timeout-minutes: 5
+ permissions:
+ contents: read
+ outputs:
+ version: ${{ steps.detect.outputs.version }}
+ need_tag: ${{ steps.detect.outputs.need_tag }}
+ need_release: ${{ steps.detect.outputs.need_release }}
+ release_ref: ${{ steps.detect.outputs.release_ref }}
+ steps:
+ - name: Check out the pushed commit (full history + tags)
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ with:
+ # fetch-depth: 0 pulls every tag so the existence check below is
+ # authoritative; a shallow clone would omit tags and mis-report.
+ fetch-depth: 0
+ persist-credentials: false
+
+ - name: Detect the newest CHANGELOG version needing a tag or a re-release
+ id: detect
+ # No ${{ }} interpolation in this run script (injection-safe, zizmor): it
+ # reads CHANGELOG.md from the checkout, queries git + gh, and writes only
+ # to $GITHUB_OUTPUT. GH_TOKEN arrives via env (not inline), so `gh release
+ # view` can read the repo's releases under the floor `contents: read`.
+ env:
+ GH_TOKEN: ${{ github.token }}
+ run: |
+ set -euo pipefail
+ # The first heading of the form "## [X.Y.Z] - " (or the historical
+ # "## [vX.Y.Z] - " style) is the newest
+ # dated release. Requiring the " - " date separator skips the
+ # "## [Unreleased]" heading, which carries no date.
+ version="$(grep -m1 -E '^## \[v?[0-9]+\.[0-9]+\.[0-9]+\] - ' CHANGELOG.md \
+ | sed -E 's/^## \[v?([0-9]+\.[0-9]+\.[0-9]+)\] - .*/\1/' || true)"
+ if [ -z "$version" ]; then
+ echo "No dated CHANGELOG version found; nothing to release."
+ echo "need_tag=false" >> "$GITHUB_OUTPUT"
+ echo "need_release=false" >> "$GITHUB_OUTPUT"
+ exit 0
+ fi
+ echo "version=$version" >> "$GITHUB_OUTPUT"
+ tag="v$version"
+ if git rev-parse -q --verify "refs/tags/$tag" >/dev/null; then
+ # The tag already exists, so it is NEVER moved (immutable). Re-release
+ # ONLY when its GitHub Release is missing: a transient publish failure
+ # must not permanently wedge the version. Build the re-release FROM the
+ # tagged commit, not the current (moved-on) main HEAD — resolve the tag
+ # to its immutable commit SHA and hand it to release.yml as `ref`.
+ echo "need_tag=false" >> "$GITHUB_OUTPUT"
+ # Deliberate fail-open: ANY non-zero from `gh release view` (a true 404
+ # or a transient rate-limit/auth blip) counts as "Release missing" and
+ # re-releases. Safe — `gh release create` (release.yml) has no --clobber,
+ # so a false positive just errors on the existing release and nothing is
+ # overwritten: one red run, no data loss. Parsing the error to isolate a
+ # real 404 was rejected — it would hinge on gh's wording and could
+ # misclassify a genuine missing-Release, re-wedging the very failure
+ # this heals.
+ if gh release view "$tag" >/dev/null 2>&1; then
+ echo "$tag is tagged and released; nothing to do."
+ echo "need_release=false" >> "$GITHUB_OUTPUT"
+ else
+ commit="$(git rev-parse --verify "$tag^{commit}")"
+ echo "$tag is tagged but has NO GitHub Release; re-releasing from $commit."
+ echo "need_release=true" >> "$GITHUB_OUTPUT"
+ echo "release_ref=$commit" >> "$GITHUB_OUTPUT"
+ fi
+ else
+ echo "$tag has no git tag; will tag and release at github.sha."
+ echo "need_tag=true" >> "$GITHUB_OUTPUT"
+ echo "need_release=true" >> "$GITHUB_OUTPUT"
+ fi
+
+ # Create and push the annotated tag with the GITHUB_TOKEN. This is the ONLY
+ # push this automation makes, and it targets a NEW tag ref only — never a
+ # branch. A GITHUB_TOKEN-pushed tag raises no tag-push event, which is why the
+ # release below invokes release.yml explicitly rather than relying on the tag.
+ tag:
+ needs: detect
+ if: needs.detect.outputs.need_tag == 'true'
+ runs-on: ubuntu-latest
+ timeout-minutes: 5
+ permissions:
+ contents: write # push the new tag ref
+ steps:
+ - name: Check out the pushed commit
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ with:
+ # github.sha = this main push's HEAD, the immutable commit to tag.
+ ref: ${{ github.sha }}
+ # persist-credentials: true is REQUIRED here so `git push` below
+ # authenticates as the GITHUB_TOKEN. Scoped to this single tag-creating
+ # job whose only step pushes one new tag ref; no other step runs, so
+ # the persisted credential is never exposed to untrusted input.
+ persist-credentials: true
+
+ - name: Create and push the annotated tag at the released commit
+ # Values arrive via env (no ${{ }} inside the script): injection-safe.
+ env:
+ TAG: v${{ needs.detect.outputs.version }}
+ COMMIT: ${{ github.sha }}
+ run: |
+ set -euo pipefail
+ git config user.name 'github-actions[bot]'
+ git config user.email '41898282+github-actions[bot]@users.noreply.github.com'
+ # Annotated tag at github.sha — the exact commit this push built and the
+ # commit release.yml will check out (github.sha) and ship.
+ git tag -a "$TAG" -m "abcd $TAG" "$COMMIT"
+ git push origin "refs/tags/$TAG"
+
+ # Cut the release by calling release.yml as a reusable workflow. Because it is
+ # CALLED (not triggered by the tag push), github.sha inside release.yml is this
+ # run's HEAD = the commit just tagged, so both of release.yml's jobs check out
+ # and ship exactly that commit — the same anti-tag-move property the tag-push
+ # entry point has. A called workflow is capped by the caller's grants, so this
+ # job hands down every scope release.yml's jobs need (contents/id-token/
+ # attestations: write).
+ release:
+ needs: [detect, tag]
+ # Run when a release is needed AND the tag is in place — either the tag job
+ # just created it (success) or it already existed and was skipped. !cancelled()
+ # is required because `tag` is skipped on the re-release path; a bare success()
+ # would skip this job too. !cancelled() still fires on a SKIPPED `tag` but NOT
+ # on a cancelled run, so a manual cancel can never publish a half-finished
+ # state. A FAILED tag job (result neither success nor skipped) blocks the
+ # release, so a half-made tag never publishes.
+ if: >-
+ !cancelled()
+ && needs.detect.outputs.need_release == 'true'
+ && (needs.tag.result == 'success' || needs.tag.result == 'skipped')
+ permissions:
+<%- if .Abcd %>
+ contents: write # release.yml: create the Release + verify the tag
+ id-token: write # release.yml: OIDC for build-provenance attestation
+ attestations: write # release.yml: publish + read back the attestations
+<%- else %>
+ contents: write # release.yml: create the Release + verify the tag
+<%- end %>
+ uses: ./.github/workflows/release.yml
+ with:
+ tag: v${{ needs.detect.outputs.version }}
+ # Empty on the fresh-tag path → release.yml falls back to github.sha (the
+ # commit just tagged). The resolved tagged-commit SHA on the re-release
+ # path, so the Release is built from the tag, not a newer main HEAD.
+ ref: ${{ needs.detect.outputs.release_ref }}
diff --git a/internal/core/launch/scaffold/templates/release.yml.tmpl b/internal/core/launch/scaffold/templates/release.yml.tmpl
new file mode 100644
index 00000000..bb990db5
--- /dev/null
+++ b/internal/core/launch/scaffold/templates/release.yml.tmpl
@@ -0,0 +1,488 @@
+name: release
+<% if .Abcd %>
+# Pushing a version tag (vX.Y.Z) first re-runs the full verification gate (gofmt,
+# build, vet, the race leg, and the deterministic record/docs/reviews gates)
+# against the PUSHED COMMIT; only if that gate is green does it cross-compile the
+# four binaries FROM THAT SAME COMMIT, generate a checksums.txt manifest over them,
+# and publish a GitHub Release with the binaries + checksums.txt attached (plus
+# their SLSA build-provenance attestations once the repo is public — attestation is
+# not offered on a user-owned private repo, so it is skipped there and re-enables
+# automatically on the public flip).
+# Nothing is pushed to any branch. Both jobs check out `github.sha` — the commit the
+# pushed tag pointed at, fixed for the whole run — never the tag NAME (which could be
+# re-pointed between jobs to swap unverified code into the build) and never the
+# default-branch tip. Net effect: a tag push yields a verified release whose binaries,
+# checksums, and reported version all come from the pushed commit.
+<%- else %>
+# Pushing a version tag (vX.Y.Z) re-runs the full verification gate against the
+# PUSHED COMMIT; only if that gate is green does it build and publish a GitHub
+# Release from that same commit. Nothing is pushed to any branch, and the whole
+# path runs on the built-in GITHUB_TOKEN. Both jobs check out `github.sha` — the
+# commit the pushed tag pointed at — never the re-resolvable tag NAME and never the
+# default-branch tip. A manual workflow_dispatch runs the rehearsal (below), which
+# arms the gate against a simulated release and publishes nothing.
+<%- end %>
+on:
+ push:
+ tags:
+ - 'v*'
+ # Called by auto-release.yml after it tags the newest dated CHANGELOG version
+ # (adr-37). `tag` and `ref` are an unenforced caller CONTRACT — --verify-tag
+ # checks only that the tag exists, not that it points at `ref`; the sole
+ # caller (auto-release) binds them: ref = `git rev-parse "$tag^{commit}"` on
+ # the re-release path, empty (-> github.sha, the just-tagged commit) on the
+ # fresh-tag path. A resolved commit SHA, never a tag name, keeps the
+ # anti-tag-move property.
+ workflow_call:
+ inputs:
+ tag:
+ required: true
+ type: string
+ ref:
+ required: false
+ type: string
+ default: ''
+ # Manual rehearsal. A workflow_dispatch runs ONLY the `rehearsal` job below and
+ # nothing that publishes: the publish job (`release`) is gated off this event,
+ # and the rehearsal arms the full gate against a simulated changelog roll and
+ # reviewed-content commit, proves the gate admits it, and creates no tag,
+ # Release, or attestation. Run it green once before the first real release (see
+ # the release-gate runbook) — it closes the private->public activation gap that
+ # otherwise surfaces only when the gate first meets a real tag.
+ workflow_dispatch:
+
+# The released tag name. workflow_call: inputs.tag (the tag auto-release just
+# made). push-tag: inputs is empty, so this falls through to github.ref_name
+# (the vX.Y.Z ref that triggered the run). Only the NAME — no job ever checks
+# out this name; both check out the immutable commit SHA below.
+env:
+ TAG: ${{ inputs.tag || github.ref_name }}
+
+# Never run two releases of the same tag concurrently, and never cancel a release
+# mid-flight — a half-published release is worse than a queued one.
+concurrency:
+ group: release-${{ inputs.tag || github.ref_name }}
+ cancel-in-progress: false
+
+# Floor for the workflow: read-only. The release job elevates itself to
+# contents: write only to create the Release and upload its assets; verify stays
+# read-only. No job pushes to any branch.
+permissions:
+ contents: read
+
+jobs:
+<%- if .Abcd %>
+ # Gate the release on the pushed commit, mirroring ci.yml's check + record-lint
+ # jobs (gofmt, build, vet, test, the race leg, the record/docs drift gates, and
+ # the reviews-charter discipline). A red step here aborts before anything is built
+ # or published. Checked out at github.sha — the commit the pushed tag pointed at,
+ # never the default-branch tip or the re-resolvable tag name — so the gate
+ # exercises exactly the commit whose binaries will ship.
+<%- else %>
+ # Gate the release on the pushed commit: the deterministic verification gate
+ # (gofmt, build, vet, test, the race leg). A red step here aborts before anything
+ # is built or published. Checked out at github.sha — the commit the pushed tag
+ # pointed at, never the default-branch tip or the re-resolvable tag name — so the
+ # gate exercises exactly the commit that will ship.
+<%- end %>
+ verify:
+ timeout-minutes: 15
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ steps:
+ - name: Check out the pushed commit
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ with:
+ # github.sha, not the tag ref: checkout re-resolves a NAMED ref at each
+ # job's start, so a tag moved after the push could differ here. The sha is
+ # the commit the pushed tag pointed at, immutable for this run. On the
+ # workflow_call path, inputs.ref (a resolved commit SHA) overrides for
+ # the re-release-from-tag case; empty inputs.ref falls through to
+ # github.sha = the caller's HEAD = the commit auto-release just tagged.
+ ref: ${{ inputs.ref || github.sha }}
+<%- if .Abcd %>
+ # Full history: the reviews-charter check (RD002, append-only) inspects
+ # committed history for post-creation edits, which a shallow checkout misses.
+<%- else %>
+ # Full history so any gate that inspects committed history stays accurate.
+<%- end %>
+ fetch-depth: 0
+ persist-credentials: false
+
+ - name: Set up Go
+ uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6
+ with:
+ go-version: '<% .GoVersion %>'
+ # cache: false — this workflow is tag-triggered; module caching in a
+ # release-triggering workflow is a cache-poisoning vector (zizmor).
+ cache: false
+
+ - name: Format (gofmt)
+ run: |
+ set -euo pipefail
+ unformatted="$(gofmt -l .)"
+ if [ -n "$unformatted" ]; then
+ echo "gofmt: these files are not formatted (run gofmt -w .):" >&2
+ echo "$unformatted" >&2
+ exit 1
+ fi
+
+ - name: Build
+ run: go build ./...
+
+ - name: Vet
+ run: go vet ./...
+
+ - name: Test
+ run: go test ./...
+
+ - name: Test (race, internal)
+ run: go test -race ./internal/...
+<%- range .ExtraGates %>
+
+ - name: <% .Name %>
+ run: <% .Run %>
+<%- end %>
+
+ # Build and publish ONLY after verify is green. Binaries are cross-compiled from
+ # the same pushed commit (github.sha) that verify just gated, so the shipped
+ # artefacts match the code that passed — even if the tag were re-pointed since.
+ release:
+ needs: verify
+ # The publish path never runs on a manual rehearsal (workflow_dispatch): that
+ # event runs the `rehearsal` job, which publishes nothing. The tag-push and
+ # workflow_call (auto-release) entry points are unaffected.
+ if: github.event_name != 'workflow_dispatch'
+ timeout-minutes: 20
+ runs-on: ubuntu-latest
+ permissions:
+<%- if .Abcd %>
+ contents: write # create the Release and upload its assets (no branch push)
+ id-token: write # mint the OIDC token attest-build-provenance signs with
+ attestations: write # publish the build-provenance attestations (public repo)
+<%- else %>
+ contents: write # create the Release (no branch push, no attestation)
+<%- end %>
+ steps:
+ - name: Check out the pushed commit
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ with:
+ # github.sha (the commit the pushed tag pointed at), not the tag NAME and
+ # not the default-branch tip, so the binaries below are built from the exact
+ # commit verify gated. inputs.ref (resolved SHA) overrides on the
+ # workflow_call re-release path, exactly as in verify.
+ ref: ${{ inputs.ref || github.sha }}
+<%- if .Abcd %>
+ # Full history: the semantic-gate resolve step below walks HEAD^2^/HEAD^
+ # to find the reviewed content commit, which a shallow (depth-1) clone
+ # cannot reach — it would abort and fail-close every release.
+<%- else %>
+ # Full history, so any history-walking gate has the commits it needs.
+<%- end %>
+ fetch-depth: 0
+ persist-credentials: false
+
+ - name: Record the default-branch tip (no-branch-commit tripwire)
+ # This release job must push NOTHING to any branch. Record the remote
+ # default-branch tip now; the closing step re-reads it and fails the run if
+ # the branch moved because of this job. Read the ref through the API with
+ # GH_TOKEN: an anonymous `git ls-remote` cannot authenticate to a private repo,
+ # and checkout ran with persist-credentials: false.
+ run: |
+ set -euo pipefail
+ sha="$(gh api "repos/${GITHUB_REPOSITORY}/git/ref/heads/${DEFAULT_BRANCH}" --jq '.object.sha')"
+ test -n "$sha"
+ echo "DEFAULT_BRANCH_START_SHA=$sha" >> "$GITHUB_ENV"
+ echo "default-branch tip at job start: $sha"
+ env:
+ DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Set up Go
+ uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6
+ with:
+ go-version: '<% .GoVersion %>'
+ cache: false
+<%- if .SemanticGates %>
+
+ # --- Semantic release gate (iss-35; dormant until the public flip) --------
+ # The LLM semantic passes (docs-currency-reviewer, the brief↔surface
+ # cross-check) cannot run in CI — no model. The maintainer runs them
+ # host-side and commits a sha-keyed PROMOTE receipt under
+ # .abcd/work/reviews// (see .abcd/development/release-gate/README.md).
+ # These steps fail-close the release unless those receipts exist and are
+ # PROMOTE, then sign them for auditable release provenance. Gated on repo
+ # visibility exactly like the binary attestation below: artifact attestation
+ # is a public-repo feature, so this gate is dormant on a private repo and
+ # activates on the public flip. Signing here is provenance/tamper-evidence,
+ # not committer-forgery-proof (a forged committed receipt would be signed
+ # too) — that residual is bounded by the identity gate + branch protection.
+ - name: Resolve the reviewed content commit (the receipt subject)
+ if: ${{ !github.event.repository.private }}
+ # A semantic-pass receipt names the commit its reviewer READ, and lives in
+ # a LATER commit — a receipt can never sit in the tree of the commit it
+ # names (adding it would change that commit's sha). So the receipts are the
+ # final commit on the release branch and name the immediately-preceding
+ # release-content commit; the gate is armed with THAT content commit, whose
+ # receipts are present in the released tree. Derivation, fail-closed:
+ # - merge (workflow_call / auto-release): the receipts commit is the
+ # merged branch tip (github.sha^2); the content it names is its parent
+ # (github.sha^2^).
+ # - direct tag push (manual escape hatch): the tag is placed on the
+ # receipts commit, so the content is its parent (github.sha^).
+ # No ${{ }} interpolation in the script (injection-safe): it reads github
+ # refs via git and writes only $GITHUB_ENV.
+ run: |
+ set -euo pipefail
+ # Resolve against HEAD — the exact commit this job checked out
+ # (inputs.ref on the re-release heal path, else github.sha). Deriving
+ # from GITHUB_SHA would diverge from the checked-out tree on the heal
+ # path (GITHUB_SHA is then the moved-on main tip) and arm the gate with
+ # a commit whose receipts are not in the released tree.
+ released="$(git rev-parse --verify HEAD)"
+ if git rev-parse -q --verify "HEAD^2" >/dev/null 2>&1; then
+ content="$(git rev-parse --verify "HEAD^2^")"
+ else
+ content="$(git rev-parse --verify "HEAD^")"
+ fi
+ test -n "$content"
+ # The named content commit must be an ancestor of the released commit —
+ # a receipt for anything off the release lineage is refused.
+ git merge-base --is-ancestor "$content" "$released"
+ echo "CONTENT_SHA=$content" >> "$GITHUB_ENV"
+ echo "reviewed content commit: $content"
+
+ - name: Semantic-gate receipts (fail-closed)
+ if: ${{ !github.event.repository.private }}
+ # Arms receipt_gate from the CLI (the workflow, not the in-tree config, is
+ # the trust root for the decision to gate and the required-gates list).
+ # CONTENT_SHA reaches this step's environment via $GITHUB_ENV (exported by
+ # the resolve step above) — no per-step env: passthrough needed.
+ run: |
+ set -euo pipefail
+ test -n "${CONTENT_SHA:-}" # never arm-nothing on an empty sha (fail closed)
+ go run ./cmd/record-lint --release-gate "$CONTENT_SHA"<% range .SemanticGates %> \
+ --require-gate <% . %><% end %>
+
+ - name: Attest the semantic-gate receipts (signed release provenance)
+ if: ${{ !github.event.repository.private }}
+ uses: actions/attest@a1948c3f048ba23858d222213b7c278aabede763 # v4.1.1
+ with:
+ subject-path: .abcd/work/reviews/${{ env.CONTENT_SHA }}/*.json
+ predicate-type: https://abcd.dev/attestations/semantic-release-gate/v1
+ # Only the immutable resolved content sha — never github.ref_name (an
+ # attacker-influenceable tag name would inject into this signed JSON).
+ predicate: |
+ {"commit": "${{ env.CONTENT_SHA }}", "verificationResult": "PROMOTE"}
+
+ - name: Verify the semantic-gate receipt attestation
+ if: ${{ !github.event.repository.private }}
+ # CONTENT_SHA reaches this step via $GITHUB_ENV (resolve step above).
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ run: |
+ set -euo pipefail
+ for r in .abcd/work/reviews/"$CONTENT_SHA"/*.json; do
+ gh attestation verify "$r" \
+ --repo "${GITHUB_REPOSITORY}" \
+ --signer-workflow "${GITHUB_REPOSITORY}/.github/workflows/release.yml" \
+ --predicate-type https://abcd.dev/attestations/semantic-release-gate/v1
+ done
+<%- end %>
+<% if .Abcd %>
+ - name: Cross-compile the four binaries (version-stamped from the tag)
+ # VERSION= stamps `abcd version` via -ldflags -X …internal/core.Version.
+ # The binaries built here are the ones checksummed, attested, and uploaded
+ # below, so the manifest, the published assets, and the reported version are
+ # all in sync.
+ run: make build VERSION="${TAG}"
+
+ - name: Generate checksums.txt over the four binaries
+ # Hash the exact binaries built above into a sha256sum-format manifest,
+ # uploaded as a release asset. No rebuild — the bytes hashed here are the
+ # bytes uploaded below.
+ run: |
+ set -euo pipefail
+ cd bin
+ sha256sum abcd-* > checksums.txt
+ cat checksums.txt
+
+ - name: Attest build provenance for the binaries and checksums.txt
+ # Signed SLSA build-provenance over the exact files uploaded below. Runs
+ # BEFORE the Release so a failed attestation aborts before publishing.
+ # Skipped on a user-owned private repo (GitHub gates the feature by repo
+ # visibility); the release still ships binaries + checksums.txt, and
+ # attestation re-enables automatically on the public flip. A skipped step is
+ # not a failure, so the publish steps still run.
+ if: ${{ !github.event.repository.private }}
+ uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1
+ with:
+ subject-path: |
+ bin/abcd-*
+ bin/checksums.txt
+
+ - name: Create the GitHub Release and upload the binaries + checksums.txt
+ # --verify-tag: the tag must already exist (it triggered this run) and point
+ # at the checked-out commit. --generate-notes lists the merged PRs since the
+ # previous tag; CHANGELOG.md remains the durable, hand-curated record.
+ run: gh release create "${TAG}" bin/abcd-* bin/checksums.txt --verify-tag --title "${TAG}" --generate-notes
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Verify the published attestation against a freshly downloaded binary
+ # Post-release gate: prove the attestation is real and retrievable. Download
+ # one binary FRESH from the just-created Release and verify it against a
+ # provenance signed BY THIS release workflow. Skipped on a private repo (no
+ # attestation was produced above); re-enables alongside attestation on the flip.
+ if: ${{ !github.event.repository.private }}
+ run: |
+ set -euo pipefail
+ DL="$RUNNER_TEMP/attest-verify"
+ mkdir -p "$DL"
+ gh release download "${TAG}" \
+ --repo "${GITHUB_REPOSITORY}" \
+ --pattern 'abcd-linux-amd64' \
+ --dir "$DL"
+ gh attestation verify "$DL/abcd-linux-amd64" \
+ --repo "${GITHUB_REPOSITORY}" \
+ --signer-workflow "${GITHUB_REPOSITORY}/.github/workflows/release.yml"
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+<%- else %>
+ - name: Build
+ run: go build ./...
+
+ - name: Create the GitHub Release and generate notes
+ # --verify-tag: the tag must already exist (auto-release created it) and
+ # point at the checked-out commit. --generate-notes lists the merged PRs
+ # since the previous tag; CHANGELOG.md remains the durable, hand-curated
+ # record. GITHUB_TOKEN-only: no personal access token, no elevated secret.
+ run: gh release create "${TAG}" --verify-tag --title "${TAG}" --generate-notes
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+<%- end %>
+
+ - name: Assert the release job pushed nothing to the default branch
+ # Close the no-branch-commit tripwire opened at job start. Runs last so it
+ # covers every preceding step. !cancelled(): the tripwire must still run when
+ # an earlier step FAILED — an abnormal run is exactly when an unexpected push
+ # most needs catching. If the tip is unchanged, done. If it moved, a push from
+ # this job would land as github-actions[bot], so a bot commit in the moved
+ # range fails the run, while an unrelated concurrent human merge is tolerated.
+ if: ${{ !cancelled() }}
+ run: |
+ set -euo pipefail
+ end="$(gh api "repos/${GITHUB_REPOSITORY}/git/ref/heads/${DEFAULT_BRANCH}" --jq '.object.sha')"
+ if [ "$end" = "${DEFAULT_BRANCH_START_SHA}" ]; then
+ echo "default branch unchanged across the release job (${end}); the job pushed nothing."
+ exit 0
+ fi
+ committers="$(gh api "repos/${GITHUB_REPOSITORY}/compare/${DEFAULT_BRANCH_START_SHA}...${end}" \
+ --jq '[.commits[].committer.login // "unknown"] | unique | join(" ")')"
+ echo "default branch moved during the release job: ${DEFAULT_BRANCH_START_SHA} -> ${end} (committers: ${committers})"
+ case " ${committers} " in
+ *" github-actions[bot] "*)
+ echo "a github-actions[bot] commit landed on the default branch during this job — the release workflow must never push to a branch" >&2
+ exit 1
+ ;;
+ esac
+ echo "movement attributed to commits outside this job; the release job itself pushed nothing."
+ env:
+ DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+
+ # The manual rehearsal (workflow_dispatch). It arms the full gate against a
+ # SIMULATED changelog roll and reviewed-content commit — reproducing the
+ # two-commit release-branch shape and the auto-release merge — proves the gate
+ # resolves and admits, and PUBLISHES NOTHING: it holds contents: read only (so
+ # it cannot create a tag, Release, or attestation even by mistake), pushes
+ # nothing, and every mutation stays in the runner's throwaway checkout. A green
+ # rehearsal is the runbook precondition for the first real release.
+ rehearsal:
+ if: github.event_name == 'workflow_dispatch'
+ timeout-minutes: 15
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ steps:
+ - name: Check out (full history)
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ with:
+ # Full history so the content-commit resolution below can walk parents,
+ # exactly as release.yml's semantic gate does.
+ fetch-depth: 0
+ persist-credentials: false
+
+ - name: Set up Go
+ uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6
+ with:
+ go-version: '<% .GoVersion %>'
+ cache: false
+
+ - name: Simulate a changelog roll and the two-commit release branch
+ # No ${{ }} interpolation (injection-safe): the script writes only the
+ # runner's throwaway checkout and $GITHUB_ENV. Nothing here is pushed.
+ run: |
+ set -euo pipefail
+ git config user.name 'rehearsal[bot]'
+ git config user.email 'rehearsal@localhost'
+ base="$(git rev-parse --verify HEAD)"
+ date="$(date -u +%Y-%m-%d)"
+ # Content commit on a scratch branch: simulate rolling [Unreleased] into a
+ # dated heading — the reviewed content commit a real release names.
+ git switch -q -c rehearsal-sim
+ if grep -q '^## \[Unreleased\]' CHANGELOG.md 2>/dev/null; then
+ awk -v d="$date" '1; /^## \[Unreleased\]/ && !done {print ""; print "## [0.0.0-rehearsal] - " d; done=1}' CHANGELOG.md > CHANGELOG.rehearsal
+ mv CHANGELOG.rehearsal CHANGELOG.md
+ else
+ printf '\n## [0.0.0-rehearsal] - %s\n' "$date" >> CHANGELOG.md
+ fi
+ git add -A
+ git commit -q -m 'rehearsal: simulated changelog roll (content commit)'
+ content="$(git rev-parse --verify HEAD)"
+ # Receipts commit: names the content commit and sits one commit later —
+ # the two-commit shape that keeps the receipt-vs-tag self-reference from
+ # ever blocking the release.
+ mkdir -p ".abcd/work/reviews/$content"
+ printf '{"rehearsal":true,"content":"%s"}\n' "$content" > ".abcd/work/reviews/$content/rehearsal.json"
+ git add -A
+ git commit -q -m 'rehearsal: simulated receipts commit'
+ # Merge into the base, reproducing the auto-release merge whose second
+ # parent is the receipts commit (so HEAD^2^ resolves the content commit).
+ git switch -q -c rehearsal-merge "$base"
+ git merge -q --no-ff -m 'rehearsal: merge simulated release' rehearsal-sim
+ echo "REHEARSAL_CONTENT_SHA=$content" >> "$GITHUB_ENV"
+
+ - name: Resolve the reviewed content commit (release.yml's derivation)
+ # Mirrors release.yml's resolve step exactly: HEAD^2^ on the merge path,
+ # HEAD^ on a direct tag. Asserts the resolution lands on the simulated
+ # content commit and is an ancestor of the released commit.
+ run: |
+ set -euo pipefail
+ released="$(git rev-parse --verify HEAD)"
+ if git rev-parse -q --verify "HEAD^2" >/dev/null 2>&1; then
+ content="$(git rev-parse --verify "HEAD^2^")"
+ else
+ content="$(git rev-parse --verify "HEAD^")"
+ fi
+ test -n "$content"
+ git merge-base --is-ancestor "$content" "$released"
+ test "$content" = "${REHEARSAL_CONTENT_SHA}"
+ echo "rehearsal: the gate would arm against content commit $content (present in the released tree)"
+<%- if .Abcd %>
+
+ - name: Arm the deterministic record gate against the simulated tree
+ # The deterministic half of the gate must pass on the simulated roll. The
+ # semantic half (record-lint --release-gate) activates with the real gate on
+ # the public flip; here the resolution above proves it would arm correctly.
+ run: go run ./cmd/record-lint
+<%- end %>
+
+ - name: Prove the gate admits and nothing was published
+ run: |
+ set -euo pipefail
+ # Build the simulated tree — the deterministic leg the gate requires.
+ go build ./...
+ echo "rehearsal: full gate armed against the simulated roll; the gate admits."
+ echo "rehearsal: nothing published — no tag, Release, or attestation was created (contents: read)."
diff --git a/internal/core/launch/scaffold/templates/runbook.md.tmpl b/internal/core/launch/scaffold/templates/runbook.md.tmpl
new file mode 100644
index 00000000..13a89e4e
--- /dev/null
+++ b/internal/core/launch/scaffold/templates/runbook.md.tmpl
@@ -0,0 +1,86 @@
+# Release gate — the pre-tag procedure
+
+This repo cuts releases the changelog-driven way (adr-37): rolling the
+accumulated `## [Unreleased]` section into a dated `## [X.Y.Z] - ` heading,
+in an ordinary reviewed PR, **is** the release decision. On merge to
+`<% .DefaultBranch %>`, `auto-release.yml` reads the newest dated heading, tags
+exactly that commit, and calls `release.yml` to verify and publish. Nothing else
+declares a release; an ordinary push with no new dated heading is a no-op.
+
+The whole path runs on the built-in `GITHUB_TOKEN` — no personal access token,
+no standing secret. Tags are immutable; a publish is idempotent and self-healing
+(a missing Release is re-cut from the tagged commit, never a moved-on HEAD).
+
+## Before the first real release: run the rehearsal
+
+`release.yml` carries a `workflow_dispatch` **rehearsal**. Trigger it once before
+the first public release. It arms the full gate against a simulated changelog
+roll and reviewed-content commit, asserts the gate admits it, and **publishes
+nothing** — no tag, no Release, no attestation. A green rehearsal is the
+precondition for trusting the gate with a real tag: it proves the gate can be
+satisfied before the repo goes public, closing the private→public activation gap
+that otherwise surfaces only at the first real release.
+
+## Deterministic gates (CI-enforced)
+
+The `release.yml` `verify` job runs these, in order, on the released commit.
+`release.yml` is authoritative; this list is the human-readable mirror.
+
+1. Format (gofmt)
+2. Build
+3. Vet
+4. Test
+5. Test (race, internal)
+<%- range $i, $g := .ExtraGates %>
+<% add $i 6 %>. <% $g.Name %>
+<%- end %>
+
+## Semantic gates (host-run, before the tag)
+<% if .SemanticGates %>
+CI cannot run these — they spawn host-side LLM agents. Run each against the exact
+commit to be tagged and record its verdict as a receipt:
+<% range .SemanticGates %>
+- `<% . %>`
+<%- end %>
+
+A receipt names the commit its reviewer **read**, and lives in a **later**
+commit — it can never sit in the tree of the commit it names (adding it would
+change that commit's sha). So the release branch is two commits: the CHANGELOG
+roll (the reviewed content commit), then the receipts naming it. On merge,
+`release.yml` arms the gate with the **content** commit (`^2^` for an
+auto-release merge, `^` for a manual tag), whose receipts are present in the
+released tree, so the receipt-vs-tag self-reference never blocks the release.
+
+Receipts live at `.abcd/work/reviews//.json`. A receipt is
+bound to its gate by its `policy.detector` value, not its filename, so one
+PROMOTE receipt cannot be copied across every gate. A missing, mismatched, HOLD,
+or wrong-detector receipt **blocks** the release (fail-closed — an un-run
+semantic pass is never a silent pass).
+<%- else %>
+No semantic detector is configured for this repo, so the deterministic gates
+alone admit a release and the receipt gate requires nothing — no host-run pass is
+silently treated as missing. Opt in later by adding detectors to the release
+gate; until then the release publishes on the deterministic gates.
+<%- end %>
+
+## Reviews charter — sha-keyed receipt directories
+
+Should this repo adopt a dated-review-dir discipline, the sha-keyed receipt
+directories under `.abcd/work/reviews//` are **exempt** from the
+dated-directory shape: they are keyed by commit, not date, so the two review
+conventions do not collide.
+
+## Procedure
+
+1. Land all work; open the release the normal way (branch → PR → merge). The
+ `verify` job gates the merge.
+<% if .SemanticGates -%>
+2. On the merged commit, run the semantic gates above and record each verdict as
+ a receipt keyed to that commit's sha.
+3. `auto-release.yml` tags `vX.Y.Z` on the merged commit and publishes. Once the
+ fail-closed gate is armed, the tag is rejected unless every semantic receipt
+ is present and PROMOTE.
+<%- else -%>
+2. `auto-release.yml` tags `vX.Y.Z` on the merged commit and publishes, gated on
+ the deterministic `verify` job alone.
+<%- end %>
diff --git a/internal/surface/cli/cli.go b/internal/surface/cli/cli.go
index 1a92f79d..85d4f37b 100644
--- a/internal/surface/cli/cli.go
+++ b/internal/surface/cli/cli.go
@@ -130,6 +130,10 @@ func NewRootCommand() *cobra.Command {
// BUNDLE, this cuts the RELEASE (version + changelog record set). They hang
// off one command because they gate the same event.
launchCmd.AddCommand(newLaunchShipCommand(&asJSON))
+ // `scaffold` writes the changelog-driven release machinery (release.yml,
+ // auto-release.yml, runbook) into a managed repo that lacks it (itd-93). It
+ // extends 04-launch because launch already owns how a release is cut and gated.
+ launchCmd.AddCommand(newLaunchScaffoldCommand(&asJSON))
root.AddCommand(launchCmd)
root.AddCommand(newChangelogCommand(&asJSON))
diff --git a/internal/surface/cli/scaffold.go b/internal/surface/cli/scaffold.go
new file mode 100644
index 00000000..df128697
--- /dev/null
+++ b/internal/surface/cli/scaffold.go
@@ -0,0 +1,95 @@
+package cli
+
+import (
+ "errors"
+ "fmt"
+ "io"
+ "os"
+
+ "github.com/REPPL/abcd-cli/internal/core/launch/scaffold"
+ "github.com/REPPL/abcd-cli/internal/termsafe"
+ "github.com/spf13/cobra"
+)
+
+// newLaunchScaffoldCommand builds `abcd launch scaffold` (itd-93, spc-14): it
+// writes the changelog-driven release machinery — release.yml, auto-release.yml,
+// and the adr-37 runbook — into a managed repo that lacks it, wired to the repo's
+// own default branch and Go version, GITHUB_TOKEN-only and injection-safe.
+//
+// It is idempotent and fail-safe (AC4): a re-run on current machinery is a no-op,
+// and a hand-edited file is refused (exit 1) rather than clobbered unless
+// --confirm is passed. Exit codes are the machine seam an operator gates on:
+//
+// - 0 — every file written or already current (a clean scaffold or a no-op).
+// - 1 — a file exists and differs; the report names it and nothing was written.
+// Re-run with --confirm to overwrite (the rendered report is the output).
+// - 2 — a structural fault (the repository or a template could not be read).
+func newLaunchScaffoldCommand(asJSON *bool) *cobra.Command {
+ var confirm bool
+ cmd := &cobra.Command{
+ Use: "scaffold [--confirm]",
+ Short: "Scaffold the changelog-driven release gate (release.yml, auto-release.yml, runbook) into this repo",
+ Args: cobra.NoArgs,
+ RunE: func(cmd *cobra.Command, _ []string) error {
+ cwd, err := os.Getwd()
+ if err != nil {
+ return err
+ }
+ rep, err := scaffold.Scaffold(scaffold.Request{RepoRoot: cwd, Confirm: confirm})
+ if err != nil && !errors.Is(err, scaffold.ErrScaffoldBlocked) {
+ // A structural fault (unreadable repo/template, failed write): exit 2,
+ // scrubbed to one line so no absolute path leaks (iss-81). A mid-write
+ // fault still carries a partial per-file report (which files landed
+ // before the fault, which were skipped) — render it first so the
+ // operator sees the disk state; a pure preflight fault has none.
+ if len(rep.Files) > 0 {
+ _ = render(cmd.OutOrStdout(), *asJSON, rep, func(w io.Writer) {
+ renderScaffold(w, rep, false)
+ })
+ }
+ return &exitError{Code: 2, Msg: "abcd launch scaffold: " + scrubPaths(err)}
+ }
+ blocked := errors.Is(err, scaffold.ErrScaffoldBlocked)
+ if rerr := render(cmd.OutOrStdout(), *asJSON, rep, func(w io.Writer) {
+ renderScaffold(w, rep, blocked)
+ }); rerr != nil {
+ return rerr
+ }
+ if blocked {
+ // A refusal is an expected outcome, not a crash: the report is the
+ // output and exit 1 the only extra signal (the embark-conflicts
+ // precedent).
+ return &exitError{Code: 1}
+ }
+ return nil
+ },
+ }
+ cmd.Flags().BoolVar(&confirm, "confirm", false, "overwrite a hand-edited scaffolded file with the current machinery")
+ return cmd
+}
+
+// renderScaffold prints the human summary of a scaffold run: the derived repo
+// facts, each file's disposition, and the overall verdict.
+func renderScaffold(w io.Writer, rep scaffold.Report, blocked bool) {
+ verdict := "scaffolded"
+ switch {
+ case blocked:
+ verdict = "REFUSED"
+ case rep.NoOp:
+ verdict = "no-op (already current)"
+ }
+ fmt.Fprintf(w, "abcd launch scaffold — %s (branch %s, go %s)\n",
+ verdict, termsafe.Sanitize(rep.DefaultBranch), termsafe.Sanitize(rep.GoVersion))
+ for _, f := range rep.Files {
+ // f.Path is a fixed repo-relative constant; f.Detail interpolates a reason.
+ fmt.Fprintf(w, " [%s] %s", f.Status, f.Path)
+ if f.Detail != "" {
+ fmt.Fprintf(w, " — %s", termsafe.Sanitize(f.Detail))
+ }
+ fmt.Fprintln(w)
+ }
+ fmt.Fprintf(w, " %d written, %d refused\n", rep.Wrote, rep.Refused)
+ if blocked {
+ fmt.Fprintln(w, " re-run with --confirm to overwrite the hand-edited file(s) with the machinery.")
+ }
+}