From 8a7caf4d1fee6674836e333b0fc905e9e5774520 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Mon, 27 Jul 2026 15:41:03 +0100 Subject: [PATCH 1/5] docs: record itd-93 Branch-A parity decision Assisted-by: Claude:claude-opus-4-8[1m] --- .abcd/work/DECISIONS.md | 1 + 1 file changed, 1 insertion(+) diff --git a/.abcd/work/DECISIONS.md b/.abcd/work/DECISIONS.md index be22928..24e5c96 100644 --- a/.abcd/work/DECISIONS.md +++ b/.abcd/work/DECISIONS.md @@ -858,3 +858,4 @@ parallel-agent merge contention bites. .abcd/.work.local/scratch/prompt-exports/). Recorded in AGENTS.md § Working-tree layout, the canonical home, rather than a new doc. - 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 — 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). From 46fd4bcb43fd7eb4e7c22bef3acff0ddbde92186 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Mon, 27 Jul 2026 16:10:33 +0100 Subject: [PATCH 2/5] feat: scaffold template + self-scaffold parity for the release gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The scaffold package renders the changelog-driven release machinery (release.yml, auto-release.yml, adr-37 runbook) from one embedded template, using <% %> delimiters so GitHub Actions ${{ }} passes through literally. AbcdSubstitutions reproduces abcd-cli's live workflows byte-for-byte (TestSelfScaffoldParity), so the proven pattern and the shipped template are one artifact; abcd-cli's own release.yml is regenerated from the template, adding only the additive workflow_dispatch rehearsal (trigger + release-job guard + non-publishing rehearsal job) — the sole sanctioned live-workflow diff (Branch A). BareSubstitutions degrades cleanly for a managed repo with no semantic detector: the deterministic Go gates alone, a generic build, GITHUB_TOKEN-only. Repo facts (default branch, Go version) are derived and validated against an injection-safe allowlist before reaching the YAML. itd-93 / spc-14. Assisted-by: Claude:claude-opus-4-8[1m] --- .github/workflows/release.yml | 105 ++++ internal/core/launch/scaffold/render.go | 122 +++++ internal/core/launch/scaffold/scaffold.go | 255 +++++++++ .../core/launch/scaffold/scaffold_test.go | 418 +++++++++++++++ .../core/launch/scaffold/substitutions.go | 46 ++ .../scaffold/templates/auto-release.yml.tmpl | 179 +++++++ .../scaffold/templates/release.yml.tmpl | 488 ++++++++++++++++++ .../launch/scaffold/templates/runbook.md.tmpl | 86 +++ 8 files changed, 1699 insertions(+) create mode 100644 internal/core/launch/scaffold/render.go create mode 100644 internal/core/launch/scaffold/scaffold.go create mode 100644 internal/core/launch/scaffold/scaffold_test.go create mode 100644 internal/core/launch/scaffold/substitutions.go create mode 100644 internal/core/launch/scaffold/templates/auto-release.yml.tmpl create mode 100644 internal/core/launch/scaffold/templates/release.yml.tmpl create mode 100644 internal/core/launch/scaffold/templates/runbook.md.tmpl diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6d91a9c..f7f650c 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/internal/core/launch/scaffold/render.go b/internal/core/launch/scaffold/render.go new file mode 100644 index 0000000..038cab4 --- /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 0000000..6e5513f --- /dev/null +++ b/internal/core/launch/scaffold/scaffold.go @@ -0,0 +1,255 @@ +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. +type FileStatus string + +const ( + // StatusWritten — the file was written (created, or overwritten under confirm). + 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" +) + +// 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. + outcomes := make([]FileOutcome, len(planned)) + writeNeeded := make([]bool, len(planned)) + for i, p := range planned { + abs := filepath.Join(req.RepoRoot, filepath.FromSlash(p.rel)) + state, detail := classify(abs, p.data) + switch state { + case StatusCurrent: + outcomes[i] = FileOutcome{Path: p.rel, Status: StatusCurrent} + case StatusWritten: // absent → to be written + writeNeeded[i] = true + outcomes[i] = FileOutcome{Path: p.rel, Status: StatusWritten} + case StatusRefused: + if req.Confirm { + writeNeeded[i] = true + outcomes[i] = FileOutcome{Path: p.rel, Status: StatusWritten, Detail: "overwritten under --confirm"} + } else { + outcomes[i] = FileOutcome{Path: p.rel, Status: StatusRefused, Detail: detail} + } + } + } + + refused := 0 + for _, o := range outcomes { + if o.Status == StatusRefused { + refused++ + } + } + if refused > 0 { + 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. + 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 { + // Report what was written before the fault; the caller renders it. The + // atomic writer never leaves a half-written file. + report.Files = outcomes + report.Wrote = wrote + return report, fmt.Errorf("scaffold: write %s: %w", p.rel, err) + } + wrote++ + } + + report.Files = outcomes + report.Wrote = wrote + report.NoOp = wrote == 0 && refused == 0 + return report, nil +} + +// classify reports whether abs is absent (→ StatusWritten, write it), byte-equal +// to want (→ StatusCurrent, no-op), or present-and-different (→ StatusRefused). +// A non-regular leaf (symlink/FIFO/device) or an unreadable existing file is +// treated as different — the scaffold never writes through a symlink silently and +// never assumes an unreadable file is safe to clobber. +func classify(abs string, want []byte) (FileStatus, string) { + got, err := fsutil.ReadGuarded(abs, maxWorkflowBytes) + if err != nil { + if os.IsNotExist(err) { + return StatusWritten, "" + } + if errors.Is(err, fsutil.ErrNotRegular) { + return StatusRefused, "existing path is not a regular file (a symlink or non-regular leaf is never written through)" + } + if errors.Is(err, fsutil.ErrTooBig) { + return StatusRefused, "existing file exceeds the size cap; this is not machinery abcd wrote" + } + return StatusRefused, "existing file is unreadable: " + err.Error() + } + if string(got) == string(want) { + return StatusCurrent, "" + } + return StatusRefused, "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 0000000..b02af93 --- /dev/null +++ b/internal/core/launch/scaffold/scaffold_test.go @@ -0,0 +1,418 @@ +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") + } +} + +// 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 0000000..464173b --- /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 0000000..b1ae9cd --- /dev/null +++ b/internal/core/launch/scaffold/templates/auto-release.yml.tmpl @@ -0,0 +1,179 @@ +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: + 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 + 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 0000000..bb990db --- /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 0000000..13a89e4 --- /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 %> From f722374b4ca8eb95d2078056a400cfdec5147b9b Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Mon, 27 Jul 2026 16:10:45 +0100 Subject: [PATCH 3/5] feat: wire abcd launch scaffold to the CLI and plugin surface abcd launch scaffold writes the release machinery into a managed repo that lacks it, wired to the repo's own default branch and Go version. 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, a structural fault exits 2. Reachable from both front doors: the CLI subcommand under launch, and the plugin markdown surface (commands/abcd/launch.md). The brief 04-launch surface doc, the surface baseline, and the generated CLI reference record the new sub-verb; CHANGELOG entry added. itd-93 / spc-14. Assisted-by: Claude:claude-opus-4-8[1m] --- .../brief/04-surfaces/04-launch.md | 1 + .abcd/development/release/surface.json | 13 +++ CHANGELOG.md | 18 ++++ commands/abcd/launch.md | 43 ++++++++- docs/reference/cli/commands.md | 12 +++ internal/surface/cli/cli.go | 4 + internal/surface/cli/scaffold.go | 87 +++++++++++++++++++ 7 files changed, 176 insertions(+), 2 deletions(-) create mode 100644 internal/surface/cli/scaffold.go diff --git a/.abcd/development/brief/04-surfaces/04-launch.md b/.abcd/development/brief/04-surfaces/04-launch.md index e169c9e..394d011 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 e99a05b..e40c0b2 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/CHANGELOG.md b/CHANGELOG.md index 60c0a7e..4130a66 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 44ed6a5..7fe6074 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 0966c2e..4309010 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/surface/cli/cli.go b/internal/surface/cli/cli.go index 1a92f79..85d4f37 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 0000000..afce991 --- /dev/null +++ b/internal/surface/cli/scaffold.go @@ -0,0 +1,87 @@ +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). + 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.") + } +} From da7ff1621a874a8f97a12fa3caca6e5688463db2 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Mon, 27 Jul 2026 16:12:43 +0100 Subject: [PATCH 4/5] feat: bare auto-release grants only contents: write to the reusable workflow The bare release.yml has no attest step, so its caller need not hand down id-token/attestations. Least-privilege for the managed-repo profile; abcd's own auto-release is unchanged (self-scaffold parity holds byte-for-byte). itd-93 / spc-14 Assisted-by: Claude:claude-opus-4-8[1m] --- internal/core/launch/scaffold/templates/auto-release.yml.tmpl | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/internal/core/launch/scaffold/templates/auto-release.yml.tmpl b/internal/core/launch/scaffold/templates/auto-release.yml.tmpl index b1ae9cd..1754245 100644 --- a/internal/core/launch/scaffold/templates/auto-release.yml.tmpl +++ b/internal/core/launch/scaffold/templates/auto-release.yml.tmpl @@ -167,9 +167,13 @@ jobs: && 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 }} From ccd103662deab8d802c26b01cefd8620d5fa0f27 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Mon, 27 Jul 2026 16:21:37 +0100 Subject: [PATCH 5/5] fix: scaffold report must not claim an unwritten file was written On the all-or-nothing refusal-abort path the first pass marked absent files StatusWritten before any write, so a run that refused a hand-edited sibling reported '[written]' for a file it never wrote and that was absent on disk (the report contradicted disk and the wrote counter). Classify now returns a disposition, a planned write is provisional (StatusSkipped) until the second pass actually writes it, and only a successful write flips it to StatusWritten; on abort or a mid-write fault the unwritten files stay skipped with a reason. The CLI now also renders the partial report on a mid-write fault before exit 2. Adds TestRefusalAbortReportMatchesDisk (watched fail before, pass after). Ruthless-review FIX-FIRST. itd-93 / spc-14 Assisted-by: Claude:claude-opus-4-8[1m] --- internal/core/launch/scaffold/scaffold.go | 96 ++++++++++++------- .../core/launch/scaffold/scaffold_test.go | 37 +++++++ internal/surface/cli/scaffold.go | 10 +- 3 files changed, 110 insertions(+), 33 deletions(-) diff --git a/internal/core/launch/scaffold/scaffold.go b/internal/core/launch/scaffold/scaffold.go index 6e5513f..943c7d6 100644 --- a/internal/core/launch/scaffold/scaffold.go +++ b/internal/core/launch/scaffold/scaffold.go @@ -20,17 +20,33 @@ const maxWorkflowBytes = 1 << 20 // 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. +// 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 written (created, or overwritten under confirm). + // 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. @@ -94,42 +110,51 @@ func Scaffold(req Request) (Report, error) { // 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. + // 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)) - state, detail := classify(abs, p.data) - switch state { - case StatusCurrent: + disp, detail := classify(abs, p.data) + switch disp { + case dispCurrent: outcomes[i] = FileOutcome{Path: p.rel, Status: StatusCurrent} - case StatusWritten: // absent → to be written + case dispAbsent: writeNeeded[i] = true - outcomes[i] = FileOutcome{Path: p.rel, Status: StatusWritten} - case StatusRefused: + outcomes[i] = FileOutcome{Path: p.rel, Status: StatusSkipped} // provisional until written + case dispDiffers: if req.Confirm { writeNeeded[i] = true - outcomes[i] = FileOutcome{Path: p.rel, Status: StatusWritten, Detail: "overwritten under --confirm"} + 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} } } } - refused := 0 - for _, o := range outcomes { - if o.Status == StatusRefused { - refused++ - } - } 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. + // 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] { @@ -137,44 +162,51 @@ func Scaffold(req Request) (Report, error) { } abs := filepath.Join(req.RepoRoot, filepath.FromSlash(p.rel)) if err := fsutil.WriteFileAtomicPreserveMode(abs, p.data); err != nil { - // Report what was written before the fault; the caller renders it. The - // atomic writer never leaves a half-written file. + // 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 && refused == 0 + report.NoOp = wrote == 0 return report, nil } -// classify reports whether abs is absent (→ StatusWritten, write it), byte-equal -// to want (→ StatusCurrent, no-op), or present-and-different (→ StatusRefused). -// A non-regular leaf (symlink/FIFO/device) or an unreadable existing file is -// treated as different — the scaffold never writes through a symlink silently and -// never assumes an unreadable file is safe to clobber. -func classify(abs string, want []byte) (FileStatus, string) { +// 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 StatusWritten, "" + return dispAbsent, "" } if errors.Is(err, fsutil.ErrNotRegular) { - return StatusRefused, "existing path is not a regular file (a symlink or non-regular leaf is never written through)" + 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 StatusRefused, "existing file exceeds the size cap; this is not machinery abcd wrote" + return dispDiffers, "existing file exceeds the size cap; this is not machinery abcd wrote" } - return StatusRefused, "existing file is unreadable: " + err.Error() + return dispDiffers, "existing file is unreadable: " + err.Error() } if string(got) == string(want) { - return StatusCurrent, "" + return dispCurrent, "" } - return StatusRefused, "existing file differs from the current machinery (hand-edited or stale)" + 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 diff --git a/internal/core/launch/scaffold/scaffold_test.go b/internal/core/launch/scaffold/scaffold_test.go index b02af93..ccd9e6b 100644 --- a/internal/core/launch/scaffold/scaffold_test.go +++ b/internal/core/launch/scaffold/scaffold_test.go @@ -347,6 +347,43 @@ func TestScaffoldIdempotentAndRefusesHandEdit(t *testing.T) { } } +// 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. diff --git a/internal/surface/cli/scaffold.go b/internal/surface/cli/scaffold.go index afce991..df12869 100644 --- a/internal/surface/cli/scaffold.go +++ b/internal/surface/cli/scaffold.go @@ -38,7 +38,15 @@ func newLaunchScaffoldCommand(asJSON *bool) *cobra.Command { 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). + // 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)