From a170693c1aeb3ffbd3bf3826c25c9a80203e45e9 Mon Sep 17 00:00:00 2001 From: "Claude (Emil)" Date: Tue, 10 Nov 2026 13:29:41 +0000 Subject: [PATCH 01/17] docs(adr): ADR 0241, a commit message never names a model; its identifier is allowed The maintainer's ruling of 2026-10-27, which #169's gate implements, reverses the commit-message half of ADR 0033's amendment of 2026-10-02. A reversal is a change of meaning, so under ADR 0068 point 2 it is a supersession: ADR 0241 supersedes that one ruling, and ModelNamedInTheRecordNotTheCommit moves to 0241's set under its key, filed unaccepted (ADR 0066). 0033's Status names the supersession, and AGENTS.md (Every commit, Never) and CONTRIBUTING.md state the ruling. The record still names the tool and the model exactly. Refs #257 Refs #169 Co-Authored-By: Claude Signed-off-by: Emil Okkels Klein --- AGENTS.md | 13 +-- CONTRIBUTING.md | 4 +- docs/adr/0033-commit-traceability.md | 5 ++ ...model-names-refused-identifiers-allowed.md | 90 +++++++++++++++++++ docs/adr/README.md | 8 ++ docs/decisions/commit-traceability.md | 9 +- ...model-names-refused-identifiers-allowed.md | 13 +++ 7 files changed, 128 insertions(+), 14 deletions(-) create mode 100644 docs/adr/0241-model-names-refused-identifiers-allowed.md create mode 100644 docs/decisions/model-names-refused-identifiers-allowed.md diff --git a/AGENTS.md b/AGENTS.md index 8f5ae498..659e304c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -159,10 +159,11 @@ Conventional commits, one logical change each, and three things in each: exactly**, and the report. The model is the identifier the session's metadata reports. A tool that will not write it says so in the record, and the maintainer fills it in: a blank model is an incomplete record. **A commit message never -carries a model's identifier** (the string an API takes to select a model); a -tool's co-author trailer naming the product is not one (ADR 0033, amended -2026-10-02). Prose says "developed with AI assistance under human review" and -**names no product**. +carries a model's name** (a family word, alone or after the product, followed +by a version); a model's identifier (the string an API takes to select a +model) is allowed, and so is a co-author trailer naming only the product +(ADR 0241, superseding ADR 0033's amendment of 2026-10-02). Prose says +"developed with AI assistance under human review" and **names no product**. ## `Varve.Store` behaviour @@ -541,8 +542,8 @@ exists. - A native dependency, or any package that ships a native asset. - `System.Uri` where an IRI is meant. Use `Varve.Iri` (ADR 0004). - A commit with no issue reference or no DCO sign-off, or one whose message - carries a model's identifier; a long-lived branch; a trunk left - un-releasable. + carries a model's name (its identifier is allowed, ADR 0241); a long-lived + branch; a trunk left un-releasable. - Reflection, `Reflection.Emit` or `dynamic` in shipped code. - A `Common` / `Core` / `Utils` / `Abstractions` package, or an upward or same-layer reference. A `.cs` file without the MPL-2.0 notice (ADR 0031). diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 64599e4f..159a748a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -162,7 +162,9 @@ and tied to issues ([ADR 0033](docs/adr/0033-commit-traceability.md)). and the model, named exactly**, and the report it returned. The model is the identifier the session's metadata reports; where the tool will not write it, the record says so and the maintainer fills it in. A commit message never -carries a model's identifier (ADR 0033, amended 2026-10-02). +carries a model's name; a model's identifier is allowed, and so is a co-author +trailer naming only the product (ADR 0241, superseding ADR 0033's amendment of +2026-10-02). Prose in `README.md`, `CONTRIBUTING.md`, `GOVERNANCE.md` and the documentation describes the project as developed with AI assistance under human review and diff --git a/docs/adr/0033-commit-traceability.md b/docs/adr/0033-commit-traceability.md index 2ec3b9e1..319ffe52 100644 --- a/docs/adr/0033-commit-traceability.md +++ b/docs/adr/0033-commit-traceability.md @@ -13,6 +13,11 @@ the records. under *The wording rule splits*: what "named exactly" means for the model, and that a commit message never carries a model's identifier. +**Superseded in part by [0241](0241-model-names-refused-identifiers-allowed.md)** +(filed 2026-11-10): the amendment's ruling on the commit message is reversed. +A commit message never names a model, and a model's identifier is allowed. The +record still names the model exactly. + ## Context The Mind Over Machine stewardship standard asks for one thing under this diff --git a/docs/adr/0241-model-names-refused-identifiers-allowed.md b/docs/adr/0241-model-names-refused-identifiers-allowed.md new file mode 100644 index 00000000..d9122750 --- /dev/null +++ b/docs/adr/0241-model-names-refused-identifiers-allowed.md @@ -0,0 +1,90 @@ +# 0241 — A commit message never names a model; a model's identifier is allowed + +## Status + +**Filed unaccepted by the session for #257, 2026-11-10** (ADR 0066). Decided +by the maintainer on 2026-10-27, in the scope of +[#169](https://github.com/Hafeok/Varve/issues/169). Acceptance is the +maintainer's act, or his delegate's for a decision he has read (ADR 0239). + +**Supersedes one ruling of [0033](0033-commit-traceability.md)**, +`ModelNamedInTheRecordNotTheCommit`, as its amendment of 2026-10-02 stated it. +The half about the record is restated here unchanged; the half about the +commit message is reversed. Under ADR 0068 point 2, that reversal is a change of +meaning, so it is a supersession, not an amendment. The ruling moves to this +ADR's set under the same key (ADR 0068 point 4). + +## Context + +ADR 0033's amendment of 2026-10-02 said a commit message never carries a +model's *identifier*, the string a provider's API takes to select a model, +and allowed a co-author trailer that names the product. Its reason was that a +commit message cannot be corrected without rewriting the trunk (ADR 0032), +and the record is where the fact can be corrected. + +In practice the trailer did the opposite. The tool these sessions use writes a +co-author trailer that names the model by its marketing name, family and +version (`Claude Opus 5.5`). `main` carried 241 such commits by 2026-10-27, +all signed and merged, so they stay. The name was in the commit, and the +identifier was barred. The identifier is what a reader can resolve exactly +and what the record holds; a name is the form that drifts and gets +advertised. + +On 2026-10-27 the maintainer ruled the other way round, for #169's gate: +**names refused, identifiers allowed.** `AGENTS.md` and `CONTRIBUTING.md` +still said the reverse, and a gate built to the ruling would contradict an +accepted decision. This ADR records the ruling so that the gate, the rule +files and the ledger agree. + +## Decision + +1. **A commit message never carries a model's name.** A name is a model + family word (`Opus`, `Sonnet`, `Haiku`, `Fable`, `Mythos`, and any family + the gate's configuration adds), alone or after `Claude`, followed by a + version: `Opus 5.5`, `Claude Sonnet 5.5`. The rule covers the subject, the + body and every trailer. +2. **A model's identifier is allowed**, in the API form a provider takes + (`claude---`), as is the bare product name in a co-author trailer: + `Co-Authored-By: Claude `. +3. **The traceability record still names the tool and the model exactly**, + unchanged from 0033's amendment: the model is the identifier the session's + metadata reports. Where the tool will not write it into a pushed file, the + record says so where the model belongs and the maintainer fills it in. A + blank model is an incomplete record. +4. **History is left as it is.** #169's gate checks the commits a pull + request or a `land/` branch adds over `main`, never `main`'s history. + +## Alternatives considered + +- **Amend 0033 instead.** Rejected: ADR 0068 point 2 makes a change of meaning + a supersession, and this reverses which form is allowed. +- **Refuse both names and identifiers.** Rejected by the maintainer's ruling. + An identifier in a commit is harmless and exact. Refusing it would also + refuse the one form a tool can write mechanically from its own metadata. +- **Keep 0033's rule and fix the trailer.** Not chosen. It leaves the rule + barring the precise form and allowing nothing that says which model wrote + the commit, while the gate that would enforce it does not exist. + +## Consequences + +- **`AGENTS.md` and `CONTRIBUTING.md` change with this ADR.** **Every + commit** and **Never** state the ruling, and so does `CONTRIBUTING.md`'s + traceability paragraph. +- **#169's gate implements this ADR**, not 0033's amendment. Its rule page + cites both: 0033 for the record, 0241 for the commit message. +- **The 241 commits on `main` that name a model stay.** Nothing rewrites the + trunk (ADR 0032). +- **Until this ADR is accepted**, `ModelNamedInTheRecordNotTheCommit` is filed + unaccepted in this ADR's set, and a citation of it is `CS0618` (ADR 0066). + +## Checks + +- **Checked against the accepted ADRs.** Touches: + - **0033:** one ruling superseded, as above. Every other ruling stands, + `TraceabilityRecordPerSession` and `ProseNamesNoProduct` included. + - **0032:** the trunk is not rewritten. + - **0066, 0068:** filed unaccepted; the ruling moves under its key. +- **Layer ownership.** None. +- **Analyzer rule.** None here. The gate is #169's, in the commit-message stage + beside `eng/issue-refs.cs` and `eng/dco.cs`. +- **Open questions owned.** None. diff --git a/docs/adr/README.md b/docs/adr/README.md index bbeb91f8..1570386f 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -448,6 +448,14 @@ Filed by the landing session for [#244](https://github.com/Hafeok/Varve/issues/2 |---:|---|---| | [0239](0239-the-maintainers-delegate.md) | The maintainer's delegate: one session on his machine may approve, land and accept for him | Filed unaccepted (#244) | +## Model names in commit messages (#257) + +Filed by the session for [#257](https://github.com/Hafeok/Varve/issues/257), recording the maintainer's ruling of 2026-10-27 for [#169](https://github.com/Hafeok/Varve/issues/169), unaccepted until he accepts it (ADR 0066). Numbered 0241: 0240 is taken on another branch. It supersedes one ruling of 0033. + +| # | Title | Status | +|---:|---|---| +| [0241](0241-model-names-refused-identifiers-allowed.md) | A commit message never names a model; a model's identifier is allowed; supersedes one ruling of 0033 | Filed unaccepted (#257) | + ## Conformance — differential testing against Oxigraph | # | Title | Status | diff --git a/docs/decisions/commit-traceability.md b/docs/decisions/commit-traceability.md index ea411ebb..f11898a4 100644 --- a/docs/decisions/commit-traceability.md +++ b/docs/decisions/commit-traceability.md @@ -35,10 +35,6 @@ decisions: statement: "Prose describes the project as developed with AI assistance under human review and names no product, while records and issues name the tool and model" accepted-by: mailto:emil@okkels-klein.dk accepted-at: 2026-09-22T00:00:00Z - - key: ModelNamedInTheRecordNotTheCommit - statement: "The traceability record names the model by the identifier the session's metadata reports, filled in by the maintainer where the tool will not write it, and a commit message never carries a model's API identifier" - accepted-by: mailto:emil@okkels-klein.dk - accepted-at: 2026-10-02T00:00:00Z - key: AgentsMdHoldsTheRules statement: "The agent rules live in the vendor-neutral AGENTS.md, and CLAUDE.md is one line pointing at it" accepted-by: mailto:emil@okkels-klein.dk @@ -48,6 +44,5 @@ decisions: The rulings of [ADR 0033](../adr/0033-commit-traceability.md) still in force, one line each. The ADR is the narrative; this is what code cites. Acceptance is transcribed from the ADR's Status (ADR 0062). -`ModelNamedInTheRecordNotTheCommit` is the ruling of the amendment of 2026-10-02. It is filed unaccepted, -and its `accepted-at` will be the date the maintainer accepts it, which can be no earlier than the -amendment (ADR 0068). +Moved to a later set: `ModelNamedInTheRecordNotTheCommit`, the ruling of the amendment of 2026-10-02, is +superseded by ADR 0241, which refuses a model's name in a commit message and allows its identifier, in its set. diff --git a/docs/decisions/model-names-refused-identifiers-allowed.md b/docs/decisions/model-names-refused-identifiers-allowed.md new file mode 100644 index 00000000..fd27d7d6 --- /dev/null +++ b/docs/decisions/model-names-refused-identifiers-allowed.md @@ -0,0 +1,13 @@ +--- +set: model-names-refused-identifiers-allowed +namespace: varve +adr: 0241 +decisions: + - key: ModelNamedInTheRecordNotTheCommit + statement: "The traceability record names the model by the identifier the session's metadata reports, filled in by the maintainer where the tool will not write it, and a commit message never carries a model's name, while a model's API identifier and the bare product name are allowed" +--- + +The ruling of [ADR 0241](../adr/0241-model-names-refused-identifiers-allowed.md), +filed unaccepted by the session for #257 (ADR 0066). It supersedes the ruling of +ADR 0033's amendment of 2026-10-02 under the same key, which moved here from +`commit-traceability` (ADR 0068 point 4). From 05d767a41af9c3421a4a921544cf3943a859a91f Mon Sep 17 00:00:00 2001 From: "Claude (Emil)" Date: Tue, 10 Nov 2026 13:29:50 +0000 Subject: [PATCH 02/17] docs(agents): issues are the work log A section after Every commit: work starts from an issue, decisions and blockers go on it when they arise, found work is filed at once, the pull request lists every issue it touches, closings are checked after landing, and abandoned work is closed as not planned. As the prompt's note of 2026-11-10 asks, "the session" in Landing and After landing is whichever session lands: the delegate lands and checks (ADR 0239), and a cloud session posts its head, lists the issues and asks. Refs #257 Co-Authored-By: Claude Signed-off-by: Emil Okkels Klein --- AGENTS.md | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 659e304c..4150ab65 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -165,6 +165,39 @@ model) is allowed, and so is a co-author trailer naming only the product (ADR 0241, superseding ADR 0033's amendment of 2026-10-02). Prose says "developed with AI assistance under human review" and **names no product**. +## Issues are the work log + +The issue, not the session's transcript or close-out, is where anyone looks to +see what is being worked on, what was decided and what is left. + +- **Starting.** Work starts from an issue. The session comments on it when it + starts (branch, and the prompt step it is working from) and posts its plan + there once the plan is agreed. Before opening an issue, search for an open + one that covers the work; a duplicate is closed pointing at the original. +- **While working.** Decisions taken, questions for the maintainer and blockers + go on the issue when they arise, not only in the close-out. The pull request + names its issues as bare #N in prose. +- **Found work is filed when it is found.** A defect, a follow-up or a deferred + part becomes its own issue at once, linked from the issue it came from. A + close-out, a TODO or a commit message is not a place to keep work. +- **Landing.** Before asking for approval, the session lists in the pull + request every issue the change touches: + - each one it resolves fully gets its own `Closes #N` trailer; + - each one it resolves in part gets a comment saying what landed and what is + left, and tracking issues (#15 and the milestone issues) get their boxes + ticked. + + "The session" here and below is whichever session lands. The maintainer's + delegate lands and checks the closings (ADR 0239); a cloud session posts its + head, lists the issues its change touches, and asks. +- **After landing.** The session checks that every issue it meant to close is + closed. If a trailer did not take, it closes the issue by hand with a comment + naming the commit on `main`. +- **Stopping.** Work abandoned or superseded is closed as not planned, with a + comment saying why and what replaces it. +- The traceability record and the close-out link the issues; they do not + replace them. + ## `Varve.Store` behaviour **`docs/spec/log-and-projection-model.md` is the authority** (version 1.8) and From c2e965e3ea354b5cec66a8be62d06e40222d1e70 Mon Sep 17 00:00:00 2001 From: "Claude (Emil)" Date: Tue, 10 Nov 2026 13:34:27 +0000 Subject: [PATCH 03/17] docs(traceability): the record for #257, issues are the work log The prompts, the plan's correction from an amendment to ADR 0241's supersession, what changed and what was found (#258). The model is left for the maintainer to fill in, as ADR 0033 provides. Closes #257 Refs #169 Co-Authored-By: Claude Signed-off-by: Emil Okkels Klein --- ...11-10-issue-257-issues-are-the-work-log.md | 75 +++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 docs/traceability/2026-11-10-issue-257-issues-are-the-work-log.md diff --git a/docs/traceability/2026-11-10-issue-257-issues-are-the-work-log.md b/docs/traceability/2026-11-10-issue-257-issues-are-the-work-log.md new file mode 100644 index 00000000..7db0e760 --- /dev/null +++ b/docs/traceability/2026-11-10-issue-257-issues-are-the-work-log.md @@ -0,0 +1,75 @@ +# Issues are the work log, and model names refused in commit messages + +> **Recorded contemporaneously**, by the session that did the work, under +> [ADR 0033](../adr/0033-commit-traceability.md). The prompts are verbatim. The +> transcript itself is held by the maintainer. + +| | | +|---|---| +| **Issues** | [#257](https://github.com/Hafeok/Varve/issues/257); [#169](https://github.com/Hafeok/Varve/issues/169) referenced | +| **Date** | 2026-11-10 | +| **Tool** | Claude Code 2.1.322, a cloud session started from the desktop app. Not the maintainer's delegate (ADR 0239 point 1) | +| **Model** | *Left for the maintainer.* The session's metadata reports the model, but this tool's configuration keeps model identifiers out of every file it pushes, so the maintainer fills it in from the session's metadata (ADR 0033, ADR 0241 point 3). | +| **Session identifier** | `session_01NbWSYQ7rmZCcSgsvBFhZMm` | +| **Branch** | `claude/dazzling-darwin-8irixp`, from `main` at 81fba3d | +| **ADRs** | [0241](../adr/0241-model-names-refused-identifiers-allowed.md) (filed unaccepted), [0033](../adr/0033-commit-traceability.md) (one ruling superseded) | + +## The prompts + +### The session prompt, step 1, as the maintainer attached it + +> ## Step 1 — AGENTS.md: "Issues are the work log" +> +> No issue covers this yet. Search for an open one first; if none exists, open one titled "AGENTS.md: issues are the work log", with the text below as its proposal. That issue is what this step's commit closes. +> +> Add this section to `AGENTS.md` directly after **Every commit**, word for word unless something in it contradicts an accepted ADR, in which case say so on the issue before changing it: +> +> *(the section, as it now stands in `AGENTS.md`, apart from the landing note below)* +> +> Also correct the model-identifier rule. The maintainer's ruling of 2026-10-27, which #169's gate implements, is: **a commit message never carries a model's name; a model's identifier is allowed.** `AGENTS.md` still says the reverse in **Every commit** ("A commit message never carries a model's identifier …") and in **Never** ("… or one whose message carries a model's identifier"). Rewrite both lines to the ruling. The traceability record still names the tool and the model exactly; that line doesn't change. If a PR for #169 is open and touches the same lines, coordinate on #169 instead of conflicting with it, and say which you did. +> +> If `CLAUDE.md` or any other agent instructions file repeats either rule, keep it consistent (CLAUDE.md only points at AGENTS.md today, per ADR 0033). +> +> Commit trailers: `Closes #` and `Refs #169`. +> +> ## Added 2026-11-10 +> +> **AGENTS.md has changed since this prompt was written.** ADR 0239 (the maintainer's delegate) amended the `accepted-by` rule: only the maintainer, or his delegate for a decision he has read, writes `accepted-by`. A cloud session is not the delegate. Fit the new section around that text. In its **Landing** and **After landing** bullets, "the session" means whichever session lands: the delegate lands and checks the closings; a cloud session posts its head, lists the issues its change touches, and asks. + +### The maintainer, in the session + +> Read AGENTS.md, then the session prompt in varve-claude-code-issue-log.md. Note that ADR 0239 has since changed AGENTS.md's accepted-by rule, so fit the new section around it. Add a step 3: #248, the descriptor date set at landing, and where the SBOM creation timestamp comes from. Report your plan for all three steps before writing code. + +> You're right that you are not the delegate: file decisions unaccepted, post your heads, and ask. Post your plan for all three steps on their issues before writing code. + +> All three plans are agreed. Go ahead. […] Step 1's amendment to ADR 0033 and its replacing key stay unaccepted, and the pull request red, until the maintainer or his delegate accepts them. Post each head when its checks other than that are green. + +## The report + +- **No issue covered the work**, so the session opened #257 with the section + as its proposal, commented when it started, and posted the plan there and + on #169. +- **The model rule contradicts an accepted decision.** + `CommitTraceability.ModelNamedInTheRecordNotTheCommit` (ADR 0033's amendment + of 2026-10-02, accepted that day) refused a model's API identifier in a + commit message. The ruling of 2026-10-27 reverses it. ADR 0068 point 2 makes + a change of meaning a supersession, not an amendment, so the plan posted + first (an amendment and a new key) was corrected on #257: **ADR 0241** + supersedes that one ruling, and the key moves to 0241's set unchanged in + name, with the new statement and no `accepted-by` (ADR 0068 point 4). + 0033's Status line names the supersession. 0240 is taken on another branch. +- **What changed:** `AGENTS.md` gains *Issues are the work log* after + **Every commit**, word for word apart from one added paragraph under + **Landing**: "the session" is whichever session lands, as the prompt's note + asks. **Every commit** and **Never** state the ruling. `CONTRIBUTING.md` + repeated the old rule and is rewritten to match. `CLAUDE.md` only points + at `AGENTS.md`, and no other instructions file states either rule. +- **Measured:** `eng/decision-sets.cs` passes with 1,468 decisions, 1,467 + accepted; the one unaccepted is this ADR's. The Release build has zero + warnings. Nothing in code cites the key, so its being unaccepted produces no + `CS0618`; whether the pull request is red on it is for the gates to say. +- **Found:** `eng/decision-report/baseline.md` is stale on `main`. + Regenerating it rewrites some 250 lines this change did not cause. It does + not gate, so it is left for a change of its own: #258. +- **#169 is coordinated, not conflicted with:** no open pull request for it + touched these lines. Its gate, rule page and port remain its own. From bb3351458b9f86f45b6a3d3625618732788751ac Mon Sep 17 00:00:00 2001 From: "Claude (Emil)" Date: Tue, 10 Nov 2026 13:35:35 +0000 Subject: [PATCH 04/17] feat(eng): land.cs checks closing trailers before a push and after main moves At both steps land.cs lists every issue a Closes, Fixes or Resolves trailer line in main..head closes, with its title, recognising a trailer as eng/issue-refs.cs does. A pull request's Completes: line is required: missing, malformed, or naming an issue no pushed trailer closes, the landing is refused, and a body it cannot read is refused too rather than skipped. A release descriptor's trailer is a commit's trailer, so it counts. After --main moves main, an issue a pushed trailer names that is still open is printed with its commit and the exit is 1; GitHub unread is 2. It closes nothing itself. Nine fixtures under tests/fixtures/land/, run by eng/ci.cs's land-fixtures job. Refs #209 Co-Authored-By: Claude Signed-off-by: Emil Okkels Klein --- eng/ci.cs | 5 + eng/land.cs | 286 +++++++++++++++++- tests/fixtures/land/README.md | 20 ++ .../land/fail-completes-malformed.json | 8 + .../land/fail-completes-without-trailer.json | 13 + .../land/fail-landed-issue-still-open.json | 20 ++ .../land/fail-landed-state-unreadable.json | 13 + .../fixtures/land/fail-no-completes-line.json | 13 + tests/fixtures/land/pass-completes-none.json | 10 + .../land/pass-completes-with-trailer.json | 10 + .../land/pass-descriptor-trailer-counts.json | 14 + .../fixtures/land/pass-landed-all-closed.json | 12 + 12 files changed, 422 insertions(+), 2 deletions(-) create mode 100644 tests/fixtures/land/README.md create mode 100644 tests/fixtures/land/fail-completes-malformed.json create mode 100644 tests/fixtures/land/fail-completes-without-trailer.json create mode 100644 tests/fixtures/land/fail-landed-issue-still-open.json create mode 100644 tests/fixtures/land/fail-landed-state-unreadable.json create mode 100644 tests/fixtures/land/fail-no-completes-line.json create mode 100644 tests/fixtures/land/pass-completes-none.json create mode 100644 tests/fixtures/land/pass-completes-with-trailer.json create mode 100644 tests/fixtures/land/pass-descriptor-trailer-counts.json create mode 100644 tests/fixtures/land/pass-landed-all-closed.json diff --git a/eng/ci.cs b/eng/ci.cs index 1f869472..591c8e16 100644 --- a/eng/ci.cs +++ b/eng/ci.cs @@ -119,6 +119,11 @@ ("formats-fixtures", "the format gate passes and refuses its fixtures as their names say", () => Run("dotnet", ["run", "eng/formats.cs", "--", "--fixtures", "tests/fixtures/formats"])), + // land.cs's closing-trailer checks, before a push and after main moved, + // on every run (#209, tests/fixtures/land/README.md). + ("land-fixtures", "land.cs refuses a missing or unmet Completes: line and reports an issue left open after landing, as its fixtures say", + () => Run("dotnet", ["run", "eng/land.cs", "--", "--fixtures", "tests/fixtures/land"])), + // The release gate's refusal of stale or red evidence from main, preview and not, proven on // every run (ADR 0215, tests/fixtures/release-freshness/README.md). ("release-freshness-fixtures", "the release gate's freshness check, preview and not, passes and refuses its fixtures as their names say", diff --git a/eng/land.cs b/eng/land.cs index 9523c8bc..f9e64f90 100644 --- a/eng/land.cs +++ b/eng/land.cs @@ -10,6 +10,7 @@ // // --name the land/ branch's name, in place of the one worked out // --print print the git commands, run none +// --fixtures judge the closing-trailer fixtures (tests/fixtures/land/) // // ADR 0088: main accepts only a commit whose checks have passed, and a human // gets one there by pushing it to land/, letting CI and `agent review` @@ -35,7 +36,20 @@ // `--main` refuses unless land/ on origin is the same commit, so main // moves only to the commit that was checked there. // -// Exit codes: 0 done, 1 refused, 2 could not run. +// Closing trailers (#209). At both steps it lists every issue a `Closes`, +// `Fixes` or `Resolves #N` trailer line in the pushed commits will close, +// with its title. A pull request's body must carry one line `Completes: #N, +// ...` or `Completes: none` (the pull request template); the landing is +// refused when that line is missing, malformed, or names an issue no trailer +// closes. A release descriptor's own trailer is a commit's trailer like any +// other, so it counts. A `--commit` landing has no pull request, so it is only +// listed. After `--main` has moved main, every issue a pushed trailer names +// must be closed: one still open is printed with its commit and the exit is +// non-zero. It never closes anything itself, and when GitHub cannot be read +// it says so and exits non-zero rather than reporting success. +// +// Exit codes: 0 done, 1 refused (or an issue left open after landing), 2 +// could not run (GitHub unreadable included). // // See docs/adr/0088-only-checked-commits-reach-main.md. @@ -43,6 +57,7 @@ #:property NoWarn=$(NoWarn);CA2266 using System.Diagnostics; +using System.Text.Json; using System.Text.RegularExpressions; const string Remote = "origin"; @@ -52,6 +67,7 @@ string? name = null; bool toMain = false; bool print = false; +string? fixtures = null; for (int i = 0; i < args.Length; i++) { @@ -69,6 +85,9 @@ case "--name" when i + 1 < args.Length: name = args[++i]; break; + case "--fixtures" when i + 1 < args.Length: + fixtures = args[++i]; + break; case "--help" or "-h": Usage(); return 0; @@ -85,6 +104,11 @@ } } +if (fixtures is not null) +{ + return Fixtures.Run(fixtures); +} + if (pullRequest is null == commit is null) { Console.Error.WriteLine("land: give a pull request number or --commit , one of them"); @@ -125,6 +149,29 @@ return 1; } + List closing = Closings.Trailers(PushedCommits(main, head)); + ListClosings(closing); + if (pullRequest is int declared) + { + string? body = PullRequestBody(declared); + if (body is null) + { + Console.Error.WriteLine($"land: could not read #{declared}'s body with the GitHub CLI, so its Completes: line cannot be checked; refusing rather than skipping it."); + return 2; + } + + List refusals = Closings.CheckCompletes(body, closing); + if (refusals.Count > 0) + { + foreach (string refusal in refusals) + { + Console.Error.WriteLine($"land: {refusal}"); + } + + return 1; + } + } + string? landed = RemoteHead(land); if (!toMain) @@ -172,7 +219,13 @@ Console.WriteLine(); Console.WriteLine($"land: main is at {head[..12]}.{(name.StartsWith("release-", StringComparison.Ordinal) ? " The release workflow now cuts the release it carries." : "")}"); - return 0; + (int afterExit, List afterSaid) = Closings.CheckClosed(closing, IssueStates(closing)); + foreach (string line in afterSaid) + { + (afterExit == 0 ? Console.Out : Console.Error).WriteLine($"land: {line}"); + } + + return afterExit; } catch (GitException exception) { @@ -180,6 +233,75 @@ return 2; } +// The commits main..head, each its sha and full message. +static List<(string Sha, string Message)> PushedCommits(string main, string head) +{ + List<(string, string)> commits = []; + foreach (string record in Git("log", "--format=%H%x00%B%x01", $"{main}..{head}").Split('\u0001', StringSplitOptions.RemoveEmptyEntries)) + { + string trimmed = record.TrimStart('\n', '\r'); + int separator = trimmed.IndexOf('\0', StringComparison.Ordinal); + if (separator > 0) + { + commits.Add((trimmed[..separator], trimmed[(separator + 1)..])); + } + } + + return commits; +} + +static void ListClosings(List closing) +{ + if (closing.Count == 0) + { + Console.WriteLine("land: the push closes no issue."); + return; + } + + Console.WriteLine("land: the push closes:"); + foreach (IGrouping issue in closing.GroupBy(static trailer => trailer.Issue).OrderBy(static group => group.Key)) + { + (int exit, string title) = Try("gh", "api", $"repos/{{owner}}/{{repo}}/issues/{issue.Key}", "--jq", ".title"); + string titled = exit == 0 ? $" {title.Trim()}" : ""; + Console.WriteLine($" #{issue.Key}{titled} ({string.Join(", ", issue.Select(static trailer => trailer.Sha[..12]))})"); + } +} + +static string? PullRequestBody(int number) +{ + (int exit, string output) = Try("gh", "api", $"repos/{{owner}}/{{repo}}/pulls/{number}", "--jq", ".body // \"\""); + return exit == 0 ? output : null; +} + +// Each issue's state, "open" or "closed", or null when GitHub cannot be read. +// GitHub closes an issue a little after the push, so an open one is asked +// again for up to a minute before it counts. +static Dictionary? IssueStates(List closing) +{ + Dictionary states = []; + int[] issues = [.. closing.Select(static trailer => trailer.Issue).Distinct()]; + for (int attempt = 0; ; attempt++) + { + foreach (int issue in issues) + { + (int exit, string output) = Try("gh", "api", $"repos/{{owner}}/{{repo}}/issues/{issue}", "--jq", ".state"); + if (exit != 0) + { + return null; + } + + states[issue] = output.Trim(); + } + + if (attempt >= 6 || states.Values.All(static state => state == "closed")) + { + return states; + } + + Thread.Sleep(TimeSpan.FromSeconds(10)); + } +} + // release-, when the head adds a descriptor that main does not have. static string? ReleaseName(string main, string head) { @@ -288,6 +410,166 @@ dotnet run eng/land.cs -- push the pull request's head to land options: --name the land/ branch's name, in place of the one worked out --print print the git commands, run none + --fixtures judge the closing-trailer fixtures """); sealed class GitException(string message) : Exception(message); + +sealed record Trailer(int Issue, string Sha); + +// The decisions, over plain values, so that the fixtures can judge them +// without git or GitHub. +static class Closings +{ + // eng/issue-refs.cs's closingTrailer: a closing keyword and a number, + // alone on a line. The subject line is not a trailer. + static readonly Regex ClosingTrailer = new(@"^\s*(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?):?\s+(?:(?[\w.-]+/[\w.-]+))?#(?\d+)\s*$", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); + + static readonly Regex CompletesLine = new(@"^[ \t]*Completes:[ \t]*(?.*?)[ \t]*$", RegexOptions.Multiline | RegexOptions.CultureInvariant); + + public static List Trailers(IEnumerable<(string Sha, string Message)> commits) + { + List trailers = []; + foreach ((string sha, string message) in commits) + { + foreach (string line in message.Split('\n').Skip(1)) + { + Match match = ClosingTrailer.Match(line.TrimEnd('\r')); + if (match.Success && (!match.Groups["repository"].Success || match.Groups["repository"].Value.Equals("Hafeok/Varve", StringComparison.OrdinalIgnoreCase))) + { + trailers.Add(new Trailer(int.Parse(match.Groups["issue"].Value, System.Globalization.CultureInfo.InvariantCulture), sha)); + } + } + } + + return trailers; + } + + // What a pull request's Completes: line refuses. Empty means it may land. + public static List CheckCompletes(string body, List closing) + { + // Text inside HTML comments is the template's guidance, not the line. + string visible = Regex.Replace(body, "", "", RegexOptions.Singleline); + MatchCollection lines = CompletesLine.Matches(visible); + if (lines.Count == 0) + { + return ["the pull request has no `Completes:` line. Add `Completes: #N, ...` naming every issue it completes, or `Completes: none` (#209)."]; + } + + if (lines.Count > 1) + { + return ["the pull request has more than one `Completes:` line; keep one."]; + } + + string value = lines[0].Groups["value"].Value; + if (value.Equals("none", StringComparison.OrdinalIgnoreCase)) + { + return []; + } + + HashSet declared = []; + foreach (string item in value.Split(',', StringSplitOptions.TrimEntries)) + { + Match number = Regex.Match(item, @"^#(\d+)$"); + if (!number.Success) + { + return [$"the `Completes:` line reads '{value}'; it is `Completes: none` or issues as #N separated by commas."]; + } + + declared.Add(int.Parse(number.Groups[1].Value, System.Globalization.CultureInfo.InvariantCulture)); + } + + HashSet closed = [.. closing.Select(static trailer => trailer.Issue)]; + return [.. declared.Where(issue => !closed.Contains(issue)).Order().Select(static issue => + $"the pull request completes #{issue}, and no pushed commit closes it. Add a `Closes #{issue}` trailer line to the commit that completes it.")]; + } + + // After main moved: 0 when every closed issue is closed, 1 when one is + // still open, 2 when the states could not be read. + public static (int Exit, List Said) CheckClosed(List closing, Dictionary? states) + { + if (closing.Count == 0) + { + return (0, []); + } + + if (states is null) + { + return (2, ["could not read the closed issues' state from GitHub (no token, or a rate limit), so the closings are not confirmed. Check them by hand."]); + } + + List said = []; + foreach (IGrouping issue in closing.GroupBy(static trailer => trailer.Issue).OrderBy(static group => group.Key)) + { + if (!states.TryGetValue(issue.Key, out string? state) || state != "closed") + { + said.Add($"#{issue.Key} is still open, though {string.Join(", ", issue.Select(static trailer => trailer.Sha[..12]))} closes it. Close it by hand with a comment naming the commit on main."); + } + } + + return said.Count == 0 + ? (0, [$"every issue the push closes is closed: {string.Join(", ", closing.Select(static trailer => $"#{trailer.Issue}").Distinct())}."]) + : (1, said); + } +} + +// tests/fixtures/land/: each case a JSON file. `step` is "push" (the +// Completes: check before a push) or "landed" (the check after main moved). +// `commits` are the pushed commits ({sha, message}); `body` the pull request's +// body; `states` the issues' states, null when GitHub cannot be read. A +// `pass-` case must pass and a `fail-` case be refused, and `says` lists text +// the output must contain. +static class Fixtures +{ + public static int Run(string directory) + { + string[] cases = [.. Directory.GetFiles(directory, "*.json").Order(StringComparer.Ordinal)]; + int wrong = 0; + foreach (string file in cases) + { + using JsonDocument fixture = JsonDocument.Parse(File.ReadAllText(file)); + JsonElement root = fixture.RootElement; + List closing = Closings.Trailers(root.GetProperty("commits").EnumerateArray() + .Select(static commit => (commit.GetProperty("sha").GetString()!, commit.GetProperty("message").GetString()!))); + string step = root.GetProperty("step").GetString()!; + (bool passes, List said) = step switch + { + "push" => Push(root, closing), + "landed" => Landed(root, closing), + _ => throw new InvalidOperationException($"{file}: unknown step '{step}'"), + }; + + string output = string.Join("\n", said); + string[] missing = root.TryGetProperty("says", out JsonElement says) + ? [.. says.EnumerateArray().Select(static line => line.GetString()!).Where(line => !output.Contains(line, StringComparison.Ordinal))] + : []; + bool expected = Path.GetFileName(file).StartsWith("pass-", StringComparison.Ordinal); + bool right = passes == expected && missing.Length == 0; + wrong += right ? 0 : 1; + Console.WriteLine((right ? "ok " : "FAIL ") + Path.GetFileName(file) + ": " + (said.Count == 0 ? "lands" : string.Join(" ", said))); + foreach (string line in missing) + { + Console.WriteLine(" the output does not say: " + line); + } + } + + Console.WriteLine(cases.Length + " cases, " + wrong + " judged wrong"); + return cases.Length > 0 && wrong == 0 ? 0 : 1; + } + + static (bool, List) Push(JsonElement root, List closing) + { + List refusals = Closings.CheckCompletes(root.GetProperty("body").GetString()!, closing); + return (refusals.Count == 0, refusals); + } + + static (bool, List) Landed(JsonElement root, List closing) + { + JsonElement states = root.GetProperty("states"); + Dictionary? read = states.ValueKind == JsonValueKind.Null + ? null + : states.EnumerateObject().ToDictionary(static state => int.Parse(state.Name, System.Globalization.CultureInfo.InvariantCulture), static state => state.Value.GetString()!); + (int exit, List said) = Closings.CheckClosed(closing, read); + return (exit == 0, said); + } +} diff --git a/tests/fixtures/land/README.md b/tests/fixtures/land/README.md new file mode 100644 index 00000000..16c6ab7c --- /dev/null +++ b/tests/fixtures/land/README.md @@ -0,0 +1,20 @@ +# `eng/land.cs`'s closing-trailer fixtures + +Cases for `eng/land.cs --fixtures` (#209). Each holds a `step`, the pushed +`commits` (`sha`, `message`) and either the pull request's `body` (`push`, the +`Completes:` check before a push) or the issues' `states` (`landed`, the check +after `--main` has moved `main`; `null` when GitHub cannot be read). `says` +lists text the output must contain. The pipeline's `land-fixtures` job runs +them on every build: a `pass-` case must pass and a `fail-` case be refused. + +| Case | Step | Why | +|---|---|---| +| `pass-completes-with-trailer` | push | `Completes: #1`, and a commit closes #1 | +| `fail-completes-without-trailer` | push | `Completes: #1`, and no commit closes it; refused naming #1 | +| `pass-completes-none` | push | `Completes: none`, no closing trailer | +| `fail-no-completes-line` | push | no `Completes:` line; one inside an HTML comment does not count | +| `fail-completes-malformed` | push | the template's placeholder left as it is | +| `pass-descriptor-trailer-counts` | push | a release descriptor's own `Closes #14` counts | +| `pass-landed-all-closed` | landed | every issue a trailer names is closed | +| `fail-landed-issue-still-open` | landed | #2 is still open; reported with its commit | +| `fail-landed-state-unreadable` | landed | GitHub cannot be read; non-zero, and it says so | diff --git a/tests/fixtures/land/fail-completes-malformed.json b/tests/fixtures/land/fail-completes-malformed.json new file mode 100644 index 00000000..fc46683e --- /dev/null +++ b/tests/fixtures/land/fail-completes-malformed.json @@ -0,0 +1,8 @@ +{ + "step": "push", + "body": "Completes: #N, ...\n", + "commits": [], + "says": [ + "it is `Completes: none` or issues as #N" + ] +} diff --git a/tests/fixtures/land/fail-completes-without-trailer.json b/tests/fixtures/land/fail-completes-without-trailer.json new file mode 100644 index 00000000..b639c986 --- /dev/null +++ b/tests/fixtures/land/fail-completes-without-trailer.json @@ -0,0 +1,13 @@ +{ + "step": "push", + "body": "Completes: #1\n", + "commits": [ + { + "sha": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "message": "fix(x): the change\n\nThe body names #1 in prose.\n\nRefs #1\nSigned-off-by: A \n" + } + ], + "says": [ + "completes #1, and no pushed commit closes it" + ] +} diff --git a/tests/fixtures/land/fail-landed-issue-still-open.json b/tests/fixtures/land/fail-landed-issue-still-open.json new file mode 100644 index 00000000..713d64b1 --- /dev/null +++ b/tests/fixtures/land/fail-landed-issue-still-open.json @@ -0,0 +1,20 @@ +{ + "step": "landed", + "commits": [ + { + "sha": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "message": "fix(x): the change\n\nThe body names #1 in prose.\n\nCloses #1\n" + }, + { + "sha": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "message": "fix(x): the change\n\nThe body names #1 in prose.\n\nCloses #2\n" + } + ], + "states": { + "1": "closed", + "2": "open" + }, + "says": [ + "#2 is still open, though bbbbbbbbbbbb closes it" + ] +} diff --git a/tests/fixtures/land/fail-landed-state-unreadable.json b/tests/fixtures/land/fail-landed-state-unreadable.json new file mode 100644 index 00000000..3d5bcfb9 --- /dev/null +++ b/tests/fixtures/land/fail-landed-state-unreadable.json @@ -0,0 +1,13 @@ +{ + "step": "landed", + "commits": [ + { + "sha": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "message": "fix(x): the change\n\nThe body names #1 in prose.\n\nCloses #1\n" + } + ], + "states": null, + "says": [ + "could not read the closed issues' state" + ] +} diff --git a/tests/fixtures/land/fail-no-completes-line.json b/tests/fixtures/land/fail-no-completes-line.json new file mode 100644 index 00000000..e0149c9d --- /dev/null +++ b/tests/fixtures/land/fail-no-completes-line.json @@ -0,0 +1,13 @@ +{ + "step": "push", + "body": "## What this changes\n\n\n", + "commits": [ + { + "sha": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "message": "fix(x): the change\n\nThe body names #1 in prose.\n\nCloses #1\n" + } + ], + "says": [ + "has no `Completes:` line" + ] +} diff --git a/tests/fixtures/land/pass-completes-none.json b/tests/fixtures/land/pass-completes-none.json new file mode 100644 index 00000000..0f3981e8 --- /dev/null +++ b/tests/fixtures/land/pass-completes-none.json @@ -0,0 +1,10 @@ +{ + "step": "push", + "body": "Completes: none\n", + "commits": [ + { + "sha": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "message": "fix(x): the change\n\nThe body names #1 in prose.\n\nRefs #1\n" + } + ] +} diff --git a/tests/fixtures/land/pass-completes-with-trailer.json b/tests/fixtures/land/pass-completes-with-trailer.json new file mode 100644 index 00000000..b15d6fc0 --- /dev/null +++ b/tests/fixtures/land/pass-completes-with-trailer.json @@ -0,0 +1,10 @@ +{ + "step": "push", + "body": "## What this changes\n\nCompletes: #1\n", + "commits": [ + { + "sha": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "message": "fix(x): the change\n\nThe body names #1 in prose.\n\nCloses #1\nSigned-off-by: A \n" + } + ] +} diff --git a/tests/fixtures/land/pass-descriptor-trailer-counts.json b/tests/fixtures/land/pass-descriptor-trailer-counts.json new file mode 100644 index 00000000..28c12b4f --- /dev/null +++ b/tests/fixtures/land/pass-descriptor-trailer-counts.json @@ -0,0 +1,14 @@ +{ + "step": "push", + "body": "Completes: #14, #202\n", + "commits": [ + { + "sha": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "message": "fix(x): the change\n\nThe body names #1 in prose.\n\nCloses #202\n" + }, + { + "sha": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "message": "release(v0.1.0-preview.20): milestone 9 part 1\n\nCloses #14\n" + } + ] +} diff --git a/tests/fixtures/land/pass-landed-all-closed.json b/tests/fixtures/land/pass-landed-all-closed.json new file mode 100644 index 00000000..9fd43581 --- /dev/null +++ b/tests/fixtures/land/pass-landed-all-closed.json @@ -0,0 +1,12 @@ +{ + "step": "landed", + "commits": [ + { + "sha": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "message": "fix(x): the change\n\nThe body names #1 in prose.\n\nCloses #1\n" + } + ], + "states": { + "1": "closed" + } +} From a903488e0f4118ec5738bad8d80987228dbaa095 Mon Sep 17 00:00:00 2001 From: "Claude (Emil)" Date: Tue, 10 Nov 2026 13:35:37 +0000 Subject: [PATCH 05/17] docs: the Completes: line in the pull request template, and in AGENTS.md The template opens with `Completes: none` and says what the line is and that it is not a closing keyword (#108). AGENTS.md's Landing and After landing bullets name the line, land.cs's refusal and its check after main moves, so the rule and the script say the same thing. Refs #209 Co-Authored-By: Claude Signed-off-by: Emil Okkels Klein --- .github/pull_request_template.md | 10 ++++++++++ AGENTS.md | 15 +++++++++++---- 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 7332e30f..86f6fd62 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -12,6 +12,16 @@ be able to decide from the body alone whether the work is sound, without reconstructing a finding from the diff. --> +Completes: none + + + ## What this changes, and why ## What was measured diff --git a/AGENTS.md b/AGENTS.md index 4150ab65..4845fac5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -181,8 +181,12 @@ see what is being worked on, what was decided and what is left. part becomes its own issue at once, linked from the issue it came from. A close-out, a TODO or a commit message is not a place to keep work. - **Landing.** Before asking for approval, the session lists in the pull - request every issue the change touches: - - each one it resolves fully gets its own `Closes #N` trailer; + request every issue the change touches, and its `Completes:` line names + those it resolves fully, or says `none`: + - each one it resolves fully gets its own `Closes #N` trailer, and + `eng/land.cs -- N` lists what the push will close and refuses a landing + whose `Completes:` line is missing or names an issue no trailer closes + (#209); - each one it resolves in part gets a comment saying what landed and what is left, and tracking issues (#15 and the milestone issues) get their boxes ticked. @@ -191,8 +195,11 @@ see what is being worked on, what was decided and what is left. delegate lands and checks the closings (ADR 0239); a cloud session posts its head, lists the issues its change touches, and asks. - **After landing.** The session checks that every issue it meant to close is - closed. If a trailer did not take, it closes the issue by hand with a comment - naming the commit on `main`. + closed: `eng/land.cs -- N --main`, once `main` has moved, prints every issue + a pushed trailer names that is still open, with its commit, and exits + non-zero, as it does when GitHub cannot be read. It closes nothing itself. + If a trailer did not take, the session closes the issue by hand with a + comment naming the commit on `main`. - **Stopping.** Work abandoned or superseded is closed as not planned, with a comment saying why and what replaces it. - The traceability record and the close-out link the issues; they do not From 4051685e6d34f109edf07f3f312a4376a0da09bd Mon Sep 17 00:00:00 2001 From: "Claude (Emil)" Date: Tue, 10 Nov 2026 13:35:56 +0000 Subject: [PATCH 06/17] docs(traceability): the record for #209, closing trailers checked at landing Closes #209 Refs #257 Co-Authored-By: Claude Signed-off-by: Emil Okkels Klein --- ...0-issue-209-closing-trailers-at-landing.md | 59 +++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 docs/traceability/2026-11-10-issue-209-closing-trailers-at-landing.md diff --git a/docs/traceability/2026-11-10-issue-209-closing-trailers-at-landing.md b/docs/traceability/2026-11-10-issue-209-closing-trailers-at-landing.md new file mode 100644 index 00000000..92271d30 --- /dev/null +++ b/docs/traceability/2026-11-10-issue-209-closing-trailers-at-landing.md @@ -0,0 +1,59 @@ +# Closing trailers checked at landing + +> **Recorded contemporaneously**, by the session that did the work, under +> [ADR 0033](../adr/0033-commit-traceability.md). The prompt is verbatim. The +> transcript itself is held by the maintainer. + +| | | +|---|---| +| **Issues** | [#209](https://github.com/Hafeok/Varve/issues/209); [#257](https://github.com/Hafeok/Varve/issues/257) and [#108](https://github.com/Hafeok/Varve/issues/108) referenced | +| **Date** | 2026-11-10 | +| **Tool** | Claude Code 2.1.322, a cloud session started from the desktop app. Not the maintainer's delegate (ADR 0239 point 1) | +| **Model** | *Left for the maintainer.* The session's metadata reports the model, but this tool's configuration keeps model identifiers out of every file it pushes, so the maintainer fills it in from the session's metadata (ADR 0033, ADR 0241 point 3). | +| **Session identifier** | `session_01NbWSYQ7rmZCcSgsvBFhZMm` | +| **Branch** | `claude/dazzling-darwin-8irixp-step2`, on step 1's branch at c2e965e | +| **ADRs** | none decided; [0088](../adr/0088-only-checked-commits-reach-main.md) and [0102](../adr/0102-a-release-is-a-descriptor.md) applied | + +## The prompt + +> ## Step 2 — #209: closing trailers checked at landing +> +> Implement #209 as written: the `Completes:` line in the PR template, `land.cs -- N` listing the issues the push will close and refusing a declared issue with no closing trailer, a missing `Completes:` line refused, the descriptor's own trailer counting, and the four fixtures under `tests/fixtures/land/` run from `eng/ci.cs`. +> +> One addition, which goes on #209 as a comment before you build it: **after `land.cs -- N --main` has fast-forwarded `main`, it checks that every issue named in a closing trailer of the pushed commits is closed.** For any still open, it prints the issue and the commit, and exits non-zero. It never closes anything itself; closing by hand stays a human or session act with a comment. A fixture covers it: a pushed trailer whose issue is still open is reported. If the GitHub state can't be read (no token, rate limit), it says so and exits non-zero, rather than reporting success. +> +> Document the `Completes:` line and the after-landing check in the step 1 section of `AGENTS.md` (the **Landing** and **After landing** bullets), so the rule and the script describe the same thing. +> +> Commit trailer: `Closes #209`. + +The maintainer agreed the plan posted on #209 ("All three plans are agreed. Go ahead."). + +## The report + +- **The addition went on #209 as a comment** before the code, with the plan. +- **`eng/land.cs`:** + - At both steps it lists every issue a closing trailer line in + `main..head` closes, with its title. A trailer is recognised as + `eng/issue-refs.cs` recognises one, and the subject line is skipped. + - It reads the pull request's `Completes:` line, ignoring HTML comments, + so the template's guidance doesn't count. It refuses a missing, + duplicated or malformed line, and a declared issue no trailer closes. + - When the pull request's body can't be read, it refuses (exit 2) rather + than skipping the check. + - A `--commit` landing has no pull request, so it only lists. + - After `--main` it asks GitHub for each issue's state, again for up to a + minute while one is open, since GitHub closes issues a little after the + push. An issue still open is printed with its commit (exit 1), and + GitHub unreadable is exit 2. + - The decisions are pure functions over plain values, so `--fixtures` + judges them with no git or GitHub. +- **Nine fixtures:** #209's four, the descriptor's trailer, a malformed + line, and three after landing (all closed, one open, unreadable). The + `land-fixtures` job runs them. The failure path was proven: a `pass-` case + copied as `fail-` gave "10 cases, 1 judged wrong", exit 1. +- **A real dry run**, `land.cs -- 253 --print`, listed #251 with its title + and refused #253 for having no `Completes:` line. **Every pull request + open when this lands needs the line added before it lands.** The template + starts new ones with `Completes: none`. +- **`AGENTS.md`'s Landing and After landing bullets** describe the line, the + refusal and the check after landing. From 05bcb3f91610010ad7a553a98387eb9ac2304726 Mon Sep 17 00:00:00 2001 From: "Claude (Emil)" Date: Tue, 10 Nov 2026 13:42:14 +0000 Subject: [PATCH 07/17] feat(eng): release.cs --sbom writes a descriptor's digests and date together Under ADR 0224 point 2 the SBOMs are created at the descriptor's date, so the two move together. --sbom runs eng/sbom.cs created at the current UTC day (or --today), replaces the sbom: block with its sbom.txt and sets date: to that day in one edit, then regenerates CHANGELOG.md. --from takes SBOMs already written and refuses them unless each was created on that day; --out says where eng/sbom.cs writes. A tagged version and a descriptor with no sbom: block are refused. --draft points at it. Gates: four fixtures under tests/fixtures/release-sbom/, run by eng/ci.cs's release-sbom-fixtures job, and a ci.yml step that rewrites the newest format-2 descriptor, renamed and backdated, in a scratch directory and requires every SBOM created at the new date and eng/sbom.cs --descriptor to match. Refs #248 Co-Authored-By: Claude Signed-off-by: Emil Okkels Klein --- .github/workflows/ci.yml | 27 +++ eng/ci.cs | 6 +- eng/release.cs | 212 +++++++++++++++++- tests/fixtures/release-sbom/README.md | 16 ++ .../fail-no-sbom-block/descriptor.yaml | 10 + .../sbom/nupkg/Varve.Iri.spdx.json | 7 + .../sbom/nupkg/Varve.Rdf.spdx.json | 7 + .../fail-no-sbom-block/sbom/sbom.txt | 3 + .../release-sbom/fail-no-sbom-block/today.txt | 1 + .../descriptor.yaml | 14 ++ .../sbom/nupkg/Varve.Iri.spdx.json | 7 + .../sbom/nupkg/Varve.Rdf.spdx.json | 7 + .../sbom/sbom.txt | 3 + .../fail-sboms-created-another-day/today.txt | 1 + .../descriptor.yaml | 13 ++ .../pass-placeholder-from-draft/expected.yaml | 15 ++ .../sbom/nupkg/Varve.Iri.spdx.json | 7 + .../sbom/nupkg/Varve.Rdf.spdx.json | 7 + .../pass-placeholder-from-draft/sbom/sbom.txt | 3 + .../pass-placeholder-from-draft/today.txt | 1 + .../descriptor.yaml | 14 ++ .../expected.yaml | 15 ++ .../sbom/nupkg/Varve.Iri.spdx.json | 7 + .../sbom/nupkg/Varve.Rdf.spdx.json | 7 + .../sbom/sbom.txt | 3 + .../pass-rewritten-on-a-later-day/today.txt | 1 + 26 files changed, 407 insertions(+), 7 deletions(-) create mode 100644 tests/fixtures/release-sbom/README.md create mode 100644 tests/fixtures/release-sbom/fail-no-sbom-block/descriptor.yaml create mode 100644 tests/fixtures/release-sbom/fail-no-sbom-block/sbom/nupkg/Varve.Iri.spdx.json create mode 100644 tests/fixtures/release-sbom/fail-no-sbom-block/sbom/nupkg/Varve.Rdf.spdx.json create mode 100644 tests/fixtures/release-sbom/fail-no-sbom-block/sbom/sbom.txt create mode 100644 tests/fixtures/release-sbom/fail-no-sbom-block/today.txt create mode 100644 tests/fixtures/release-sbom/fail-sboms-created-another-day/descriptor.yaml create mode 100644 tests/fixtures/release-sbom/fail-sboms-created-another-day/sbom/nupkg/Varve.Iri.spdx.json create mode 100644 tests/fixtures/release-sbom/fail-sboms-created-another-day/sbom/nupkg/Varve.Rdf.spdx.json create mode 100644 tests/fixtures/release-sbom/fail-sboms-created-another-day/sbom/sbom.txt create mode 100644 tests/fixtures/release-sbom/fail-sboms-created-another-day/today.txt create mode 100644 tests/fixtures/release-sbom/pass-placeholder-from-draft/descriptor.yaml create mode 100644 tests/fixtures/release-sbom/pass-placeholder-from-draft/expected.yaml create mode 100644 tests/fixtures/release-sbom/pass-placeholder-from-draft/sbom/nupkg/Varve.Iri.spdx.json create mode 100644 tests/fixtures/release-sbom/pass-placeholder-from-draft/sbom/nupkg/Varve.Rdf.spdx.json create mode 100644 tests/fixtures/release-sbom/pass-placeholder-from-draft/sbom/sbom.txt create mode 100644 tests/fixtures/release-sbom/pass-placeholder-from-draft/today.txt create mode 100644 tests/fixtures/release-sbom/pass-rewritten-on-a-later-day/descriptor.yaml create mode 100644 tests/fixtures/release-sbom/pass-rewritten-on-a-later-day/expected.yaml create mode 100644 tests/fixtures/release-sbom/pass-rewritten-on-a-later-day/sbom/nupkg/Varve.Iri.spdx.json create mode 100644 tests/fixtures/release-sbom/pass-rewritten-on-a-later-day/sbom/nupkg/Varve.Rdf.spdx.json create mode 100644 tests/fixtures/release-sbom/pass-rewritten-on-a-later-day/sbom/sbom.txt create mode 100644 tests/fixtures/release-sbom/pass-rewritten-on-a-later-day/today.txt diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5ee2bc34..a04bac04 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -554,6 +554,33 @@ jobs: --out "$RUNNER_TEMP/sbom-$version" --syft "${{ steps.syft.outputs.cmd }}" --descriptor "$descriptor" done + - name: A descriptor's digests rewritten on a later day + # #248, docs/releases.md rule 3 (amended 2026-11-10): eng/release.cs + # --sbom writes the SBOMs and sets date: to the day it ran, in one + # edit, and eng/sbom.cs --descriptor, created at that date, matches + # every line. The descriptor is the newest with an sbom: block, renamed + # and backdated, in a scratch releases/ directory, so nothing in the + # repository changes. + if: runner.os == 'Linux' + shell: bash + run: | + set -euo pipefail + version=v0.0.0-sbom-rewrite + scratch="$RUNNER_TEMP/sbom-rewrite" + mkdir -p "$scratch/releases" + newest=$(grep -l '^sbom:' releases/v*.yaml | sort -V | tail -1) + sed -e "s/^version: .*/version: $version/" -e "s/^date: .*/date: 2026-01-01/" \ + "$newest" > "$scratch/releases/$version.yaml" + dotnet run eng/release.cs -- --sbom "$version" --releases "$scratch/releases" --out "$scratch/written" + today=$(date -u +%F) + grep -qx "date: $today" "$scratch/releases/$version.yaml" \ + || { echo "::error::release.cs --sbom did not set date: to $today"; exit 1; } + if grep -rhoE '"created": *"[^"]*"' "$scratch/written" | grep -v "\"$today"'T00:00:00Z"' ; then + echo "::error::an SBOM was not created at ${today}T00:00:00Z"; exit 1 + fi + dotnet run eng/sbom.cs -- --version "${version#v}" --created "$today" \ + --out "$scratch/again" --descriptor "$scratch/releases/$version.yaml" + - name: Upload the test results # #126: every run keeps its TRX, pass or fail, so a slow or red run # can be explained test by test from its own artifacts. diff --git a/eng/ci.cs b/eng/ci.cs index 591c8e16..75aae6a4 100644 --- a/eng/ci.cs +++ b/eng/ci.cs @@ -119,9 +119,13 @@ ("formats-fixtures", "the format gate passes and refuses its fixtures as their names say", () => Run("dotnet", ["run", "eng/formats.cs", "--", "--fixtures", "tests/fixtures/formats"])), + // release.cs --sbom's rewrite: the block and the date in one edit, and + // SBOMs created on another day refused (#248, tests/fixtures/release-sbom/README.md). + ("release-sbom-fixtures", "release.cs --sbom rewrites a descriptor's digests and date together, and refuses SBOMs created on another day", + () => Run("dotnet", ["run", "eng/release.cs", "--", "--sbom-fixtures", "tests/fixtures/release-sbom"])), // land.cs's closing-trailer checks, before a push and after main moved, // on every run (#209, tests/fixtures/land/README.md). - ("land-fixtures", "land.cs refuses a missing or unmet Completes: line and reports an issue left open after landing, as its fixtures say", + ("land-fixtures", "land.cs refuses a missing or unmet Completes: line and a descriptor dated over three days before landing, and reports an issue left open after landing, as its fixtures say", () => Run("dotnet", ["run", "eng/land.cs", "--", "--fixtures", "tests/fixtures/land"])), // The release gate's refusal of stale or red evidence from main, preview and not, proven on diff --git a/eng/release.cs b/eng/release.cs index 5e03ab94..c86e035a 100644 --- a/eng/release.cs +++ b/eng/release.cs @@ -8,6 +8,9 @@ // dotnet run eng/release.cs -- --check [--base ] // dotnet run eng/release.cs -- --plan [--dry-run] [--landing] [--since ] [--fallback ] [--offline] // dotnet run eng/release.cs -- --draft +// dotnet run eng/release.cs -- --sbom [--syft ] [--binary ]... [--image

=]... +// [--today ] [--from

| --out ] +// dotnet run eng/release.cs -- --sbom-fixtures tests/fixtures/release-sbom // dotnet run eng/release.cs -- --notes --into // dotnet run eng/release.cs -- --fixtures tests/fixtures/releases // @@ -79,6 +82,26 @@ // and notes, for publish.yml. --fixtures runs every case under the directory: // each must fail, and say what its expect.txt says (tests/fixtures/releases/). // +// --- --sbom (#248) ----------------------------------------------------------------- +// +// Rewrites a pending descriptor's `sbom:` block and its `date:` in one edit, +// then CHANGELOG.md. Under ADR 0224 point 2 (decision D3) the SBOMs are created +// at the descriptor's date, so the two move together: the day the digests are +// written is today (UTC, or --today), eng/sbom.cs is run with --created that +// day, its sbom.txt replaces the block, and `date:` becomes that day +// (docs/releases.md rule 3, amended 2026-11-10). --syft, --binary and --image +// pass through to eng/sbom.cs, which writes to --out (artifacts/sbom by +// default). --from takes SBOMs already written +// there instead, and refuses them unless every one was created on that day. +// A tagged version is refused (rule 9), and so is a descriptor with no +// `sbom:` block, which keeps its date. eng/land.cs refuses a descriptor dated +// more than three days before the landing day and prints this command. +// +// --sbom-fixtures runs the rewrite over every case under the directory, each +// a descriptor.yaml, an sbom/ directory as eng/sbom.cs writes it, today.txt +// and, for a pass- case, expected.yaml; a fail- case must be refused +// (tests/fixtures/release-sbom/README.md). +// // Exit codes: 0 valid (or planned), 1 findings, 2 could not run. // // See docs/adr/0102-a-release-is-a-descriptor.md. @@ -111,6 +134,10 @@ bool dryRun = false; bool landing = false; bool offline = false; +string? today = null; +string? sbomFrom = null; +string? sbomOut = null; +List sbomArguments = []; string[] arguments = args; for (int i = 0; i < arguments.Length; i++) @@ -120,7 +147,7 @@ case "--check" or "--plan": mode = arguments[i][2..]; break; - case "--draft" or "--notes" or "--fixtures" when i + 1 < arguments.Length: + case "--draft" or "--notes" or "--fixtures" or "--sbom" or "--sbom-fixtures" when i + 1 < arguments.Length: mode = arguments[i][2..]; modeArgument = arguments[++i]; break; @@ -151,6 +178,19 @@ case "--offline": offline = true; break; + case "--today" when i + 1 < arguments.Length: + today = arguments[++i]; + break; + case "--from" when i + 1 < arguments.Length: + sbomFrom = Path.GetFullPath(arguments[++i]); + break; + case "--out" when i + 1 < arguments.Length: + sbomOut = Path.GetFullPath(arguments[++i]); + break; + case "--syft" or "--binary" or "--image" when i + 1 < arguments.Length: + sbomArguments.Add(arguments[i]); + sbomArguments.Add(arguments[++i]); + break; default: Console.Error.WriteLine($"release: '{arguments[i]}' is not an option (see the header of eng/release.cs)."); return 2; @@ -168,6 +208,9 @@ "draft" => Draft(repository, modeArgument!), "notes" => Notes(repository, releasesPath, modeArgument!, into), "fixtures" => await Fixtures(repository, Path.GetFullPath(modeArgument!)), + "sbom" => Sbom(repository, releasesPath, modeArgument!, today, sbomFrom, sbomOut, sbomArguments, + regenerateChangelog: string.Equals(releasesPath, Path.Combine(repositoryRoot, ReleaseFormat.Directory), StringComparison.Ordinal)), + "sbom-fixtures" => SbomFixtures(Path.GetFullPath(modeArgument!)), _ => Usage(), }; } @@ -179,7 +222,7 @@ static int Usage() { - Console.Error.WriteLine("release: one of --check, --plan, --draft , --notes --into , --fixtures ."); + Console.Error.WriteLine("release: one of --check, --plan, --draft , --notes --into , --sbom , --fixtures , --sbom-fixtures ."); return 2; } @@ -859,10 +902,10 @@ static int Draft(Repository repository, string requested) draft.Append(CultureInfo.InvariantCulture, $"# {key}\n"); } - draft.Append("# Every artefact's SBOM digest (ADR 0224 point 7), written after a restore with the descriptor's date:\n"); - draft.Append(CultureInfo.InvariantCulture, $"# dotnet run eng/sbom.cs -- --version {version} --created {DateTime.UtcNow:yyyy-MM-dd} --out artifacts/sbom \\\n"); - draft.Append("# --binary ... --image linux/amd64= --image linux/arm64=\n"); - draft.Append("# and paste artifacts/sbom/sbom.txt below; publish.yml checks every line before anything is pushed.\n"); + draft.Append("# Every artefact's SBOM digest (ADR 0224 point 7), written after a restore. This rewrites the block\n"); + draft.Append("# and sets date: to the day it ran, which is the SBOMs' creation time (docs/releases.md rule 3):\n"); + draft.Append(CultureInfo.InvariantCulture, $"# dotnet run eng/release.cs -- --sbom {version} --syft --binary ...\n"); + draft.Append("# publish.yml checks every line before anything is pushed.\n"); draft.Append("sbom:\n"); draft.Append(" - \n"); draft.Append("accepted-by: mailto:\n"); @@ -981,6 +1024,106 @@ static async Task Fixtures(Repository repository, string root) return repository.Exists("origin/main") ? "origin/main" : repository.Exists("main") ? "main" : null; } +// --- --sbom ------------------------------------------------------------------------ + +static int Sbom(Repository repository, string releasesPath, string requested, string? today, string? from, string? output, List sbomArguments, bool regenerateChangelog) +{ + string version = requested.StartsWith('v') ? requested : "v" + requested; + string path = Path.Combine(releasesPath, version + ".yaml"); + + if (!File.Exists(path)) + { + Console.Error.WriteLine($"release: no descriptor at {repository.Relative(path)}."); + return 2; + } + + if (repository.Tags().Contains(version)) + { + Console.Error.WriteLine($"release: {version} is tagged, and a cut descriptor is never edited (docs/releases.md rule 9)."); + return 1; + } + + string day = today ?? DateTime.UtcNow.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture); + if (!DateOnly.TryParseExact(day, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out _)) + { + Console.Error.WriteLine($"release: --today '{day}' is not yyyy-mm-dd."); + return 2; + } + + if (from is not null && output is not null) + { + Console.Error.WriteLine("release: --from reads SBOMs already written and --out names where to write them; give one."); + return 2; + } + + string directory = from ?? output ?? Path.Combine(repository.Root, "artifacts", "sbom"); + if (from is null) + { + List generate = ["run", "eng/sbom.cs", "--", "--version", version[1..], "--created", day, "--out", directory, .. sbomArguments]; + Console.WriteLine($"$ dotnet {string.Join(' ', generate)}"); + if (RunProcess("dotnet", generate) != 0) + { + Console.Error.WriteLine("release: eng/sbom.cs failed; the descriptor is unchanged."); + return 1; + } + } + + (string? rewritten, string? refusal) = SbomRewrite.Apply(File.ReadAllText(path), directory, day); + if (rewritten is null) + { + Console.Error.WriteLine($"release: {repository.Relative(path)}: {refusal}"); + return 1; + } + + File.WriteAllText(path, rewritten); + Console.WriteLine($"release: {repository.Relative(path)}: the sbom: block rewritten, and date: {day}."); + + if (!regenerateChangelog) + { + Console.WriteLine("release: --releases is not releases/, so CHANGELOG.md is left as it is."); + return 0; + } + + List changelog = ["run", "eng/changelog.cs", "--", "--release", version]; + Console.WriteLine($"$ dotnet {string.Join(' ', changelog)}"); + return RunProcess("dotnet", changelog) == 0 ? 0 : 1; +} + +static int RunProcess(string file, List arguments) +{ + ProcessStartInfo start = new() { FileName = file, UseShellExecute = false }; + foreach (string argument in arguments) + { + start.ArgumentList.Add(argument); + } + + using Process process = Process.Start(start) ?? throw new InvalidOperationException($"could not start {file}"); + process.WaitForExit(); + return process.ExitCode; +} + +static int SbomFixtures(string directory) +{ + int wrong = 0; + string[] cases = [.. Directory.GetDirectories(directory).Order(StringComparer.Ordinal)]; + foreach (string folder in cases) + { + string name = Path.GetFileName(folder); + string day = File.ReadAllText(Path.Combine(folder, "today.txt")).Trim(); + (string? rewritten, string? refusal) = SbomRewrite.Apply(File.ReadAllText(Path.Combine(folder, "descriptor.yaml")), Path.Combine(folder, "sbom"), day); + bool expected = name.StartsWith("pass-", StringComparison.Ordinal); + string expectedPath = Path.Combine(folder, "expected.yaml"); + bool right = expected + ? rewritten is not null && File.Exists(expectedPath) && rewritten == File.ReadAllText(expectedPath).ReplaceLineEndings("\n") + : rewritten is null; + wrong += right ? 0 : 1; + Console.WriteLine((right ? "ok " : "FAIL ") + name + ": " + (refusal ?? $"rewritten, date: {day}")); + } + + Console.WriteLine(cases.Length + " cases, " + wrong + " judged wrong"); + return cases.Length > 0 && wrong == 0 ? 0 : 1; +} + static string FindRepositoryRoot() { DirectoryInfo? directory = new(Directory.GetCurrentDirectory()); @@ -1227,3 +1370,60 @@ public Api(string token, string repository) return response.IsSuccessStatusCode ? JsonDocument.Parse(await response.Content.ReadAsStringAsync()) : null; } } + +// The rewrite itself, over text, so that the fixtures can judge it. +static class SbomRewrite +{ + static readonly Regex DateLine = new(@"^date:[ \t]*\S*[ \t]*$", RegexOptions.Multiline | RegexOptions.CultureInvariant); + + // The descriptor with its sbom: block replaced by /sbom.txt and + // its date: set to day, or why not. + public static (string? Rewritten, string? Refusal) Apply(string descriptor, string directory, string day) + { + string text = descriptor.ReplaceLineEndings("\n"); + string sbomTxt = Path.Combine(directory, "sbom.txt"); + if (!File.Exists(sbomTxt)) + { + return (null, $"no sbom.txt in {directory}; run eng/sbom.cs first, or drop --from."); + } + + string expectedCreated = day + "T00:00:00Z"; + foreach (string document in Directory.EnumerateFiles(directory, "*.spdx.json", SearchOption.AllDirectories).Order(StringComparer.Ordinal)) + { + using JsonDocument spdx = JsonDocument.Parse(File.ReadAllText(document)); + string? created = spdx.RootElement.TryGetProperty("creationInfo", out JsonElement info) && info.TryGetProperty("created", out JsonElement value) ? value.GetString() : null; + if (created != expectedCreated) + { + return (null, $"{Path.GetRelativePath(directory, document)} was created at {created ?? "no time"}, not {expectedCreated}; the digests and the date must be written on the same day (ADR 0224 point 2)."); + } + } + + if (DateLine.Matches(text).Count != 1) + { + return (null, "the descriptor has no single date: line."); + } + + List lines = [.. text.Split('\n')]; + int start = lines.IndexOf("sbom:"); + if (start < 0) + { + return (null, "the descriptor has no sbom: block, so its digests and date stay as they are (format 1)."); + } + + int end = start + 1; + while (end < lines.Count && lines[end].StartsWith(" ", StringComparison.Ordinal)) + { + end++; + } + + string[] block = File.ReadAllText(sbomTxt).ReplaceLineEndings("\n").Split('\n', StringSplitOptions.RemoveEmptyEntries); + if (block.Length == 0 || block.Any(static line => !line.StartsWith(" - ", StringComparison.Ordinal))) + { + return (null, "sbom.txt is empty, or a line is not ` - : sha256:`."); + } + + lines.RemoveRange(start + 1, end - start - 1); + lines.InsertRange(start + 1, block); + return (DateLine.Replace(string.Join('\n', lines), "date: " + day), null); + } +} diff --git a/tests/fixtures/release-sbom/README.md b/tests/fixtures/release-sbom/README.md new file mode 100644 index 00000000..dcba717e --- /dev/null +++ b/tests/fixtures/release-sbom/README.md @@ -0,0 +1,16 @@ +# `eng/release.cs --sbom`'s rewrite fixtures + +Cases for `eng/release.cs --sbom-fixtures` (#248). Each is a directory: a +`descriptor.yaml`; an `sbom/` directory as `eng/sbom.cs` writes it (its +`sbom.txt`, and SPDX documents whose `creationInfo.created` is what matters); +`today.txt`, the day the rewrite runs; and, for a `pass-` case, +`expected.yaml`, the descriptor the rewrite must write, byte for byte. A +`fail-` case must be refused. The pipeline's `release-sbom-fixtures` job runs +them on every build. + +| Case | Why | +|---|---| +| `pass-rewritten-on-a-later-day` | the block is replaced by `sbom.txt`, and `date:` moves from 2026-11-01 to the day the SBOMs were created | +| `pass-placeholder-from-draft` | `--draft`'s placeholder line is replaced the same way | +| `fail-sboms-created-another-day` | the SBOMs were created at 2026-11-01, not the rewrite's day: digests and date would disagree (ADR 0224 point 2) | +| `fail-no-sbom-block` | a format 1 descriptor has no block; its date stays | diff --git a/tests/fixtures/release-sbom/fail-no-sbom-block/descriptor.yaml b/tests/fixtures/release-sbom/fail-no-sbom-block/descriptor.yaml new file mode 100644 index 00000000..2be39489 --- /dev/null +++ b/tests/fixtures/release-sbom/fail-no-sbom-block/descriptor.yaml @@ -0,0 +1,10 @@ +format: 2 +version: v9.9.9-preview.1 +title: A fixture release +date: 2026-11-01 +basis: + - issue: 1 + - storage-format: 1 +accepted-by: mailto:maintainer@example.org +summary: | + A fixture. diff --git a/tests/fixtures/release-sbom/fail-no-sbom-block/sbom/nupkg/Varve.Iri.spdx.json b/tests/fixtures/release-sbom/fail-no-sbom-block/sbom/nupkg/Varve.Iri.spdx.json new file mode 100644 index 00000000..04958a26 --- /dev/null +++ b/tests/fixtures/release-sbom/fail-no-sbom-block/sbom/nupkg/Varve.Iri.spdx.json @@ -0,0 +1,7 @@ +{ + "spdxVersion": "SPDX-2.3", + "name": "Varve.Iri", + "creationInfo": { + "created": "2026-11-10T00:00:00Z" + } +} diff --git a/tests/fixtures/release-sbom/fail-no-sbom-block/sbom/nupkg/Varve.Rdf.spdx.json b/tests/fixtures/release-sbom/fail-no-sbom-block/sbom/nupkg/Varve.Rdf.spdx.json new file mode 100644 index 00000000..4c67e20e --- /dev/null +++ b/tests/fixtures/release-sbom/fail-no-sbom-block/sbom/nupkg/Varve.Rdf.spdx.json @@ -0,0 +1,7 @@ +{ + "spdxVersion": "SPDX-2.3", + "name": "Varve.Rdf", + "creationInfo": { + "created": "2026-11-10T00:00:00Z" + } +} diff --git a/tests/fixtures/release-sbom/fail-no-sbom-block/sbom/sbom.txt b/tests/fixtures/release-sbom/fail-no-sbom-block/sbom/sbom.txt new file mode 100644 index 00000000..520355c6 --- /dev/null +++ b/tests/fixtures/release-sbom/fail-no-sbom-block/sbom/sbom.txt @@ -0,0 +1,3 @@ + - image: ghcr.io/hafeok/varve sha256:3333333333333333333333333333333333333333333333333333333333333333 + - nupkg: Varve.Iri sha256:4444444444444444444444444444444444444444444444444444444444444444 + - nupkg: Varve.Rdf sha256:5555555555555555555555555555555555555555555555555555555555555555 diff --git a/tests/fixtures/release-sbom/fail-no-sbom-block/today.txt b/tests/fixtures/release-sbom/fail-no-sbom-block/today.txt new file mode 100644 index 00000000..0c36dfdc --- /dev/null +++ b/tests/fixtures/release-sbom/fail-no-sbom-block/today.txt @@ -0,0 +1 @@ +2026-11-10 diff --git a/tests/fixtures/release-sbom/fail-sboms-created-another-day/descriptor.yaml b/tests/fixtures/release-sbom/fail-sboms-created-another-day/descriptor.yaml new file mode 100644 index 00000000..8a7826fc --- /dev/null +++ b/tests/fixtures/release-sbom/fail-sboms-created-another-day/descriptor.yaml @@ -0,0 +1,14 @@ +format: 2 +version: v9.9.9-preview.1 +title: A fixture release +date: 2026-11-01 +basis: + - issue: 1 + - storage-format: 1 +# Every artefact's SBOM digest. +sbom: + - nupkg: Varve.Iri sha256:1111111111111111111111111111111111111111111111111111111111111111 + - nupkg: Varve.Rdf sha256:2222222222222222222222222222222222222222222222222222222222222222 +accepted-by: mailto:maintainer@example.org +summary: | + A fixture. diff --git a/tests/fixtures/release-sbom/fail-sboms-created-another-day/sbom/nupkg/Varve.Iri.spdx.json b/tests/fixtures/release-sbom/fail-sboms-created-another-day/sbom/nupkg/Varve.Iri.spdx.json new file mode 100644 index 00000000..0965fed2 --- /dev/null +++ b/tests/fixtures/release-sbom/fail-sboms-created-another-day/sbom/nupkg/Varve.Iri.spdx.json @@ -0,0 +1,7 @@ +{ + "spdxVersion": "SPDX-2.3", + "name": "Varve.Iri", + "creationInfo": { + "created": "2026-11-01T00:00:00Z" + } +} diff --git a/tests/fixtures/release-sbom/fail-sboms-created-another-day/sbom/nupkg/Varve.Rdf.spdx.json b/tests/fixtures/release-sbom/fail-sboms-created-another-day/sbom/nupkg/Varve.Rdf.spdx.json new file mode 100644 index 00000000..d03c0669 --- /dev/null +++ b/tests/fixtures/release-sbom/fail-sboms-created-another-day/sbom/nupkg/Varve.Rdf.spdx.json @@ -0,0 +1,7 @@ +{ + "spdxVersion": "SPDX-2.3", + "name": "Varve.Rdf", + "creationInfo": { + "created": "2026-11-01T00:00:00Z" + } +} diff --git a/tests/fixtures/release-sbom/fail-sboms-created-another-day/sbom/sbom.txt b/tests/fixtures/release-sbom/fail-sboms-created-another-day/sbom/sbom.txt new file mode 100644 index 00000000..520355c6 --- /dev/null +++ b/tests/fixtures/release-sbom/fail-sboms-created-another-day/sbom/sbom.txt @@ -0,0 +1,3 @@ + - image: ghcr.io/hafeok/varve sha256:3333333333333333333333333333333333333333333333333333333333333333 + - nupkg: Varve.Iri sha256:4444444444444444444444444444444444444444444444444444444444444444 + - nupkg: Varve.Rdf sha256:5555555555555555555555555555555555555555555555555555555555555555 diff --git a/tests/fixtures/release-sbom/fail-sboms-created-another-day/today.txt b/tests/fixtures/release-sbom/fail-sboms-created-another-day/today.txt new file mode 100644 index 00000000..0c36dfdc --- /dev/null +++ b/tests/fixtures/release-sbom/fail-sboms-created-another-day/today.txt @@ -0,0 +1 @@ +2026-11-10 diff --git a/tests/fixtures/release-sbom/pass-placeholder-from-draft/descriptor.yaml b/tests/fixtures/release-sbom/pass-placeholder-from-draft/descriptor.yaml new file mode 100644 index 00000000..17707936 --- /dev/null +++ b/tests/fixtures/release-sbom/pass-placeholder-from-draft/descriptor.yaml @@ -0,0 +1,13 @@ +format: 2 +version: v9.9.9-preview.1 +title: A fixture release +date: 2026-11-01 +basis: + - issue: 1 + - storage-format: 1 +# Every artefact's SBOM digest. +sbom: + - +accepted-by: mailto:maintainer@example.org +summary: | + A fixture. diff --git a/tests/fixtures/release-sbom/pass-placeholder-from-draft/expected.yaml b/tests/fixtures/release-sbom/pass-placeholder-from-draft/expected.yaml new file mode 100644 index 00000000..05d5f341 --- /dev/null +++ b/tests/fixtures/release-sbom/pass-placeholder-from-draft/expected.yaml @@ -0,0 +1,15 @@ +format: 2 +version: v9.9.9-preview.1 +title: A fixture release +date: 2026-11-10 +basis: + - issue: 1 + - storage-format: 1 +# Every artefact's SBOM digest. +sbom: + - image: ghcr.io/hafeok/varve sha256:3333333333333333333333333333333333333333333333333333333333333333 + - nupkg: Varve.Iri sha256:4444444444444444444444444444444444444444444444444444444444444444 + - nupkg: Varve.Rdf sha256:5555555555555555555555555555555555555555555555555555555555555555 +accepted-by: mailto:maintainer@example.org +summary: | + A fixture. diff --git a/tests/fixtures/release-sbom/pass-placeholder-from-draft/sbom/nupkg/Varve.Iri.spdx.json b/tests/fixtures/release-sbom/pass-placeholder-from-draft/sbom/nupkg/Varve.Iri.spdx.json new file mode 100644 index 00000000..04958a26 --- /dev/null +++ b/tests/fixtures/release-sbom/pass-placeholder-from-draft/sbom/nupkg/Varve.Iri.spdx.json @@ -0,0 +1,7 @@ +{ + "spdxVersion": "SPDX-2.3", + "name": "Varve.Iri", + "creationInfo": { + "created": "2026-11-10T00:00:00Z" + } +} diff --git a/tests/fixtures/release-sbom/pass-placeholder-from-draft/sbom/nupkg/Varve.Rdf.spdx.json b/tests/fixtures/release-sbom/pass-placeholder-from-draft/sbom/nupkg/Varve.Rdf.spdx.json new file mode 100644 index 00000000..4c67e20e --- /dev/null +++ b/tests/fixtures/release-sbom/pass-placeholder-from-draft/sbom/nupkg/Varve.Rdf.spdx.json @@ -0,0 +1,7 @@ +{ + "spdxVersion": "SPDX-2.3", + "name": "Varve.Rdf", + "creationInfo": { + "created": "2026-11-10T00:00:00Z" + } +} diff --git a/tests/fixtures/release-sbom/pass-placeholder-from-draft/sbom/sbom.txt b/tests/fixtures/release-sbom/pass-placeholder-from-draft/sbom/sbom.txt new file mode 100644 index 00000000..520355c6 --- /dev/null +++ b/tests/fixtures/release-sbom/pass-placeholder-from-draft/sbom/sbom.txt @@ -0,0 +1,3 @@ + - image: ghcr.io/hafeok/varve sha256:3333333333333333333333333333333333333333333333333333333333333333 + - nupkg: Varve.Iri sha256:4444444444444444444444444444444444444444444444444444444444444444 + - nupkg: Varve.Rdf sha256:5555555555555555555555555555555555555555555555555555555555555555 diff --git a/tests/fixtures/release-sbom/pass-placeholder-from-draft/today.txt b/tests/fixtures/release-sbom/pass-placeholder-from-draft/today.txt new file mode 100644 index 00000000..0c36dfdc --- /dev/null +++ b/tests/fixtures/release-sbom/pass-placeholder-from-draft/today.txt @@ -0,0 +1 @@ +2026-11-10 diff --git a/tests/fixtures/release-sbom/pass-rewritten-on-a-later-day/descriptor.yaml b/tests/fixtures/release-sbom/pass-rewritten-on-a-later-day/descriptor.yaml new file mode 100644 index 00000000..8a7826fc --- /dev/null +++ b/tests/fixtures/release-sbom/pass-rewritten-on-a-later-day/descriptor.yaml @@ -0,0 +1,14 @@ +format: 2 +version: v9.9.9-preview.1 +title: A fixture release +date: 2026-11-01 +basis: + - issue: 1 + - storage-format: 1 +# Every artefact's SBOM digest. +sbom: + - nupkg: Varve.Iri sha256:1111111111111111111111111111111111111111111111111111111111111111 + - nupkg: Varve.Rdf sha256:2222222222222222222222222222222222222222222222222222222222222222 +accepted-by: mailto:maintainer@example.org +summary: | + A fixture. diff --git a/tests/fixtures/release-sbom/pass-rewritten-on-a-later-day/expected.yaml b/tests/fixtures/release-sbom/pass-rewritten-on-a-later-day/expected.yaml new file mode 100644 index 00000000..05d5f341 --- /dev/null +++ b/tests/fixtures/release-sbom/pass-rewritten-on-a-later-day/expected.yaml @@ -0,0 +1,15 @@ +format: 2 +version: v9.9.9-preview.1 +title: A fixture release +date: 2026-11-10 +basis: + - issue: 1 + - storage-format: 1 +# Every artefact's SBOM digest. +sbom: + - image: ghcr.io/hafeok/varve sha256:3333333333333333333333333333333333333333333333333333333333333333 + - nupkg: Varve.Iri sha256:4444444444444444444444444444444444444444444444444444444444444444 + - nupkg: Varve.Rdf sha256:5555555555555555555555555555555555555555555555555555555555555555 +accepted-by: mailto:maintainer@example.org +summary: | + A fixture. diff --git a/tests/fixtures/release-sbom/pass-rewritten-on-a-later-day/sbom/nupkg/Varve.Iri.spdx.json b/tests/fixtures/release-sbom/pass-rewritten-on-a-later-day/sbom/nupkg/Varve.Iri.spdx.json new file mode 100644 index 00000000..04958a26 --- /dev/null +++ b/tests/fixtures/release-sbom/pass-rewritten-on-a-later-day/sbom/nupkg/Varve.Iri.spdx.json @@ -0,0 +1,7 @@ +{ + "spdxVersion": "SPDX-2.3", + "name": "Varve.Iri", + "creationInfo": { + "created": "2026-11-10T00:00:00Z" + } +} diff --git a/tests/fixtures/release-sbom/pass-rewritten-on-a-later-day/sbom/nupkg/Varve.Rdf.spdx.json b/tests/fixtures/release-sbom/pass-rewritten-on-a-later-day/sbom/nupkg/Varve.Rdf.spdx.json new file mode 100644 index 00000000..4c67e20e --- /dev/null +++ b/tests/fixtures/release-sbom/pass-rewritten-on-a-later-day/sbom/nupkg/Varve.Rdf.spdx.json @@ -0,0 +1,7 @@ +{ + "spdxVersion": "SPDX-2.3", + "name": "Varve.Rdf", + "creationInfo": { + "created": "2026-11-10T00:00:00Z" + } +} diff --git a/tests/fixtures/release-sbom/pass-rewritten-on-a-later-day/sbom/sbom.txt b/tests/fixtures/release-sbom/pass-rewritten-on-a-later-day/sbom/sbom.txt new file mode 100644 index 00000000..520355c6 --- /dev/null +++ b/tests/fixtures/release-sbom/pass-rewritten-on-a-later-day/sbom/sbom.txt @@ -0,0 +1,3 @@ + - image: ghcr.io/hafeok/varve sha256:3333333333333333333333333333333333333333333333333333333333333333 + - nupkg: Varve.Iri sha256:4444444444444444444444444444444444444444444444444444444444444444 + - nupkg: Varve.Rdf sha256:5555555555555555555555555555555555555555555555555555555555555555 diff --git a/tests/fixtures/release-sbom/pass-rewritten-on-a-later-day/today.txt b/tests/fixtures/release-sbom/pass-rewritten-on-a-later-day/today.txt new file mode 100644 index 00000000..0c36dfdc --- /dev/null +++ b/tests/fixtures/release-sbom/pass-rewritten-on-a-later-day/today.txt @@ -0,0 +1 @@ +2026-11-10 From ea8e0c572df4ecad1bae7d76f7dc63e6729203d7 Mon Sep 17 00:00:00 2001 From: "Claude (Emil)" Date: Tue, 10 Nov 2026 13:42:16 +0000 Subject: [PATCH 08/17] feat(eng): land.cs refuses a descriptor dated more than three days before landing At both steps, each release descriptor the head adds or changes is judged against the landing day (UTC): a date: more than three days earlier, or none, is refused, printing the release.cs --sbom command that writes the digests and the date again. Three fixtures join tests/fixtures/land/: four days refused, three days passing, no date refused. Refs #248 Co-Authored-By: Claude Signed-off-by: Emil Okkels Klein --- eng/land.cs | 85 ++++++++++++++++++- tests/fixtures/land/README.md | 6 +- .../fail-date-four-days-before-landing.json | 10 +++ tests/fixtures/land/fail-date-missing.json | 9 ++ .../pass-date-three-days-before-landing.json | 6 ++ 5 files changed, 111 insertions(+), 5 deletions(-) create mode 100644 tests/fixtures/land/fail-date-four-days-before-landing.json create mode 100644 tests/fixtures/land/fail-date-missing.json create mode 100644 tests/fixtures/land/pass-date-three-days-before-landing.json diff --git a/eng/land.cs b/eng/land.cs index f9e64f90..597aec78 100644 --- a/eng/land.cs +++ b/eng/land.cs @@ -48,6 +48,12 @@ // non-zero. It never closes anything itself, and when GitHub cannot be read // it says so and exits non-zero rather than reporting success. // +// A descriptor's date (#248). A release descriptor the head adds or changes is +// refused, at both steps, when its `date:` is more than three days before the +// landing day (UTC): the date is the day its SBOM digests were last written +// (docs/releases.md rule 3, amended 2026-11-10), and land.cs prints the +// command that writes them again. +// // Exit codes: 0 done, 1 refused (or an issue left open after landing), 2 // could not run (GitHub unreadable included). // @@ -149,6 +155,26 @@ return 1; } + DateOnly landingDay = DateOnly.FromDateTime(DateTime.UtcNow); + List stale = []; + foreach ((string version, string date) in PendingDescriptors(main, head)) + { + if (Dates.Check(version, date, landingDay) is string refusal) + { + stale.Add(refusal); + } + } + + if (stale.Count > 0) + { + foreach (string refusal in stale) + { + Console.Error.WriteLine($"land: {refusal}"); + } + + return 1; + } + List closing = Closings.Trailers(PushedCommits(main, head)); ListClosings(closing); if (pullRequest is int declared) @@ -302,6 +328,26 @@ static void ListClosings(List closing) } } +// Each release descriptor the head adds or changes over main: its version and +// its date: as written at the head. +static List<(string Version, string Date)> PendingDescriptors(string main, string head) +{ + List<(string, string)> descriptors = []; + foreach (string path in Git("diff", "--name-only", "--diff-filter=AM", main, head, "--", "releases/").Split('\n', StringSplitOptions.RemoveEmptyEntries)) + { + Match name = Regex.Match(path, @"^releases/(v[^/]+)\.yaml$"); + if (!name.Success) + { + continue; + } + + Match date = Regex.Match(Git("show", $"{head}:{path}"), @"^date:[ \t]*(\S*)", RegexOptions.Multiline); + descriptors.Add((name.Groups[1].Value, date.Success ? date.Groups[1].Value : "")); + } + + return descriptors; +} + // release-, when the head adds a descriptor that main does not have. static string? ReleaseName(string main, string head) { @@ -513,10 +559,32 @@ public static (int Exit, List Said) CheckClosed(List closing, D } } +// A descriptor's date against the landing day (#248): null when it may land. +static class Dates +{ + public const int Window = 3; + + public static string? Check(string version, string date, DateOnly landingDay) + { + string rewrite = $"dotnet run eng/release.cs -- --sbom {version} --syft --binary ..."; + if (!DateOnly.TryParseExact(date, "yyyy-MM-dd", System.Globalization.CultureInfo.InvariantCulture, System.Globalization.DateTimeStyles.None, out DateOnly day)) + { + return $"releases/{version}.yaml has no date: as yyyy-mm-dd ('{date}'). Write its digests and date again:\n {rewrite}"; + } + + int age = landingDay.DayNumber - day.DayNumber; + return age <= Window + ? null + : $"releases/{version}.yaml is dated {date}, {age} days before the landing day {landingDay:yyyy-MM-dd}; at most {Window} are allowed, because the date is the day its SBOM digests were written (docs/releases.md rule 3). Write them again, which sets the date, then commit, push and ask for approval of the new head:\n {rewrite}"; + } +} + // tests/fixtures/land/: each case a JSON file. `step` is "push" (the // Completes: check before a push) or "landed" (the check after main moved). -// `commits` are the pushed commits ({sha, message}); `body` the pull request's -// body; `states` the issues' states, null when GitHub cannot be read. A +// A "date" case holds a descriptor's `version`, `date` and the `landing` day, +// and no commits. `commits` are the pushed commits ({sha, message}); `body` +// the pull request's body; `states` the issues' states, null when GitHub +// cannot be read. A // `pass-` case must pass and a `fail-` case be refused, and `says` lists text // the output must contain. static class Fixtures @@ -529,13 +597,15 @@ public static int Run(string directory) { using JsonDocument fixture = JsonDocument.Parse(File.ReadAllText(file)); JsonElement root = fixture.RootElement; - List closing = Closings.Trailers(root.GetProperty("commits").EnumerateArray() - .Select(static commit => (commit.GetProperty("sha").GetString()!, commit.GetProperty("message").GetString()!))); + List closing = root.TryGetProperty("commits", out JsonElement commits) + ? Closings.Trailers(commits.EnumerateArray().Select(static commit => (commit.GetProperty("sha").GetString()!, commit.GetProperty("message").GetString()!))) + : []; string step = root.GetProperty("step").GetString()!; (bool passes, List said) = step switch { "push" => Push(root, closing), "landed" => Landed(root, closing), + "date" => Date(root), _ => throw new InvalidOperationException($"{file}: unknown step '{step}'"), }; @@ -563,6 +633,13 @@ public static int Run(string directory) return (refusals.Count == 0, refusals); } + static (bool, List) Date(JsonElement root) + { + string? refusal = Dates.Check(root.GetProperty("version").GetString()!, root.GetProperty("date").GetString()!, + DateOnly.ParseExact(root.GetProperty("landing").GetString()!, "yyyy-MM-dd", System.Globalization.CultureInfo.InvariantCulture)); + return (refusal is null, refusal is null ? [] : [refusal]); + } + static (bool, List) Landed(JsonElement root, List closing) { JsonElement states = root.GetProperty("states"); diff --git a/tests/fixtures/land/README.md b/tests/fixtures/land/README.md index 16c6ab7c..72360c23 100644 --- a/tests/fixtures/land/README.md +++ b/tests/fixtures/land/README.md @@ -4,7 +4,8 @@ Cases for `eng/land.cs --fixtures` (#209). Each holds a `step`, the pushed `commits` (`sha`, `message`) and either the pull request's `body` (`push`, the `Completes:` check before a push) or the issues' `states` (`landed`, the check after `--main` has moved `main`; `null` when GitHub cannot be read). `says` -lists text the output must contain. The pipeline's `land-fixtures` job runs +lists text the output must contain. A `date` case holds a descriptor's +`version` and `date` and the `landing` day (#248). The pipeline's `land-fixtures` job runs them on every build: a `pass-` case must pass and a `fail-` case be refused. | Case | Step | Why | @@ -18,3 +19,6 @@ them on every build: a `pass-` case must pass and a `fail-` case be refused. | `pass-landed-all-closed` | landed | every issue a trailer names is closed | | `fail-landed-issue-still-open` | landed | #2 is still open; reported with its commit | | `fail-landed-state-unreadable` | landed | GitHub cannot be read; non-zero, and it says so | +| `fail-date-four-days-before-landing` | date | dated four days before the landing day; refused, printing the `--sbom` command | +| `pass-date-three-days-before-landing` | date | three days is inside the window | +| `fail-date-missing` | date | no `date:` to judge | diff --git a/tests/fixtures/land/fail-date-four-days-before-landing.json b/tests/fixtures/land/fail-date-four-days-before-landing.json new file mode 100644 index 00000000..57ed5d90 --- /dev/null +++ b/tests/fixtures/land/fail-date-four-days-before-landing.json @@ -0,0 +1,10 @@ +{ + "step": "date", + "version": "v0.1.0-preview.21", + "date": "2026-11-06", + "landing": "2026-11-10", + "says": [ + "4 days before the landing day 2026-11-10", + "dotnet run eng/release.cs -- --sbom v0.1.0-preview.21" + ] +} diff --git a/tests/fixtures/land/fail-date-missing.json b/tests/fixtures/land/fail-date-missing.json new file mode 100644 index 00000000..b791bdb1 --- /dev/null +++ b/tests/fixtures/land/fail-date-missing.json @@ -0,0 +1,9 @@ +{ + "step": "date", + "version": "v0.1.0-preview.21", + "date": "", + "landing": "2026-11-10", + "says": [ + "has no date: as yyyy-mm-dd" + ] +} diff --git a/tests/fixtures/land/pass-date-three-days-before-landing.json b/tests/fixtures/land/pass-date-three-days-before-landing.json new file mode 100644 index 00000000..7dd52e4b --- /dev/null +++ b/tests/fixtures/land/pass-date-three-days-before-landing.json @@ -0,0 +1,6 @@ +{ + "step": "date", + "version": "v0.1.0-preview.21", + "date": "2026-11-07", + "landing": "2026-11-10" +} From b1197f162f9f779a8b85a7435798ac865e7b9084 Mon Sep 17 00:00:00 2001 From: "Claude (Emil)" Date: Tue, 10 Nov 2026 13:42:16 +0000 Subject: [PATCH 09/17] docs: a descriptor's date is the day its digests were last written docs/releases.md rule 3, amended 2026-11-10 with the old text struck and kept, and rule 11's "written, not typed" naming release.cs --sbom. ADR 0102 gets a dated amendment, add-only, saying the same, since rule 3 restates its line on date; no ruling changes, and ADR 0224 is untouched. AGENTS.md's close-out names the command and land.cs's refusal. Refs #248 Co-Authored-By: Claude Signed-off-by: Emil Okkels Klein --- AGENTS.md | 6 ++- docs/adr/0102-a-release-is-a-descriptor.md | 43 ++++++++++++++++++++++ docs/releases.md | 32 +++++++++++++--- 3 files changed, 73 insertions(+), 8 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 4845fac5..de2a7e3e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -95,8 +95,10 @@ severity on every project in `src/`. Every ADR is also a decision set in **Every milestone ends in a release, and a milestone PR carries its descriptor** (ADR 0102, `docs/releases.md`). The close-out: - adds `releases/.yaml` (`dotnet run eng/release.cs -- --draft - ` starts it), with a commit whose trailer line says `Closes - #`; + ` starts it, and `-- --sbom ` writes its digests and sets + its `date:` to that day, whenever they change), with a commit whose + trailer line says `Closes #`; `eng/land.cs` refuses one + dated more than three days before the landing (#248); - runs `dotnet run eng/changelog.cs -- --release `, because `CHANGELOG.md` is the projection of `releases/` and never edited by hand; - brings `README.md`'s **Status** section current: its opening line names the diff --git a/docs/adr/0102-a-release-is-a-descriptor.md b/docs/adr/0102-a-release-is-a-descriptor.md index 26929055..645e57a9 100644 --- a/docs/adr/0102-a-release-is-a-descriptor.md +++ b/docs/adr/0102-a-release-is-a-descriptor.md @@ -42,6 +42,10 @@ revisited. [0224](0224-supply-chain-transparency.md): descriptor format 2, with an `sbom:` list naming every artefact and its SBOM's digest; see the end. +**Amended 2026-11-10** (ADR 0068), by #248: a format-2 descriptor's `date` +is the day its `sbom:` digests were last written, and `eng/land.cs` refuses +one more than three days old; see the end. + ## Context A release is a decision, and this repository files decisions: an ADR, a @@ -413,3 +417,42 @@ sbom: **The ledger.** The rulings of this ADR stand; the ruling of this block is `TheDescriptorNamesEveryArtefactsSbom`, unaccepted until the maintainer accepts it. + +## Amendment, 2026-11-10 — `date` follows the digests + +Filed by the session for #248 (step 3 of the "issues are the work log" +session), on the maintainer's ruling on #248 of 2026-11-10, unaccepted until +he accepts it (ADR 0066). It adds detail to what `date` means for a format-2 +descriptor, and leaves ADR 0224 as it is. + +**The problem.** `date` was the day the descriptor was written, set once by +`--draft`. A milestone pull request takes days to land, and its digests are +rewritten on the way, while the date stays. Under ADR 0224 point 2 (D3) every +SBOM is created at `date`, so a correction by hand changes every digest: +preview.18's descriptor says 2026-10-27, and its digests were last written on +2026-11-06, the day it landed. + +**The detail.** The preparation date is **the day the digests were last +written**, which is the preparation date of the artefacts being released: +- `eng/release.cs -- --sbom ` runs `eng/sbom.cs` created at the + current UTC day, replaces the `sbom:` block with its `sbom.txt` and sets + `date:` to that day in one edit, then regenerates `CHANGELOG.md`; +- `eng/land.cs -- N` refuses a descriptor the head adds or changes whose + `date:` is more than three days before the landing day (UTC), and prints + that command. Three days is the release gate's freshness window; +- a descriptor with no `sbom:` block keeps its date, and a cut descriptor is + never edited (rule 9), so preview.18 keeps 2026-10-27. + +`CHANGELOG.md` is still a projection that does not depend on when the cut +ran. `docs/releases.md` rule 3 and rule 11 say the same. + +**Not chosen:** +- a second field, `sbom-created:`, beside a free `date:`: a format bump to + fix a date, for two fields that move together anyway; +- the landing day exactly: a landing across midnight, or one waiting a day, + would need a new head and a new run for nothing; +- the tag's date: `CHANGELOG.md` is generated inside the pull request, + before any tag exists. + +**The ledger.** No ruling changes; the block adds detail to the descriptor's +`date` (ADR 0068 point 2). diff --git a/docs/releases.md b/docs/releases.md index f5379da2..442761bb 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -21,7 +21,7 @@ pull request at its approved head cuts it. format: 2 # mandatory; the format this file conforms to version: v0.1.0-preview.2 # v, prerelease allowed; equals the file's name title: The protocols and the server # one line; the tag message is " — " -date: 2026-10-20 # the preparation date; CHANGELOG.md's heading +date: 2026-10-20 # the day the sbom: digests were last written; CHANGELOG.md's heading commit: 0123…cdef # optional; a retro-cut only (§3). Omitted is normal basis: # mandatory; resolved at the commit under release - issue: 11 # each milestone issue it closes, at least one @@ -65,9 +65,27 @@ takes them for numbers. nuget.org cannot be replaced. 2. **The title carries no version.** The tag message and the GitHub Release's title are composed as `<version> — <title>`. -3. **`date` is the preparation date**, the day the descriptor was written. +3. **`date` is the preparation date**, ~~the day the descriptor was written~~. It is the heading of the version's section in `CHANGELOG.md`, which is a projection and must not depend on when the cut ran. + + **Amended 2026-11-10** (#248, under ADR 0102's amendment of that day): + the preparation date is **the day the descriptor's `sbom:` digests were + last written**, which is the day the artefacts being released were + prepared. The SBOMs are created at `date` (ADR 0224 point 2, unchanged), + so the two move together: + - `eng/release.cs -- --sbom <version>` writes the SBOMs, replaces the + `sbom:` block and sets `date:` to the current UTC day in one edit, and + regenerates `CHANGELOG.md` (rule 11); + - `eng/land.cs -- N` refuses a descriptor the pull request adds or + changes whose `date:` is more than three days before the landing day + (UTC), and prints that command. Three days is the release gate's + freshness window, and it spares a landing that crosses midnight or waits + out one red run; + - a descriptor with no `sbom:` block (format 1) keeps its date as it is. + + `CHANGELOG.md` still does not depend on when the cut ran. A descriptor + already cut keeps its date (rule 9): preview.18's stays 2026-10-27. 4. **`commit` is optional, and omitting it is normal.** A pull request cannot know the sha it will land as, so the default is "the commit that lands this file". When set, it must be a full sha and an ancestor of @@ -114,12 +132,14 @@ takes them for numbers. - **written, not typed.** After a restore, run: ```bash - dotnet run eng/sbom.cs -- --version <version without v> --created <date> \ - --out artifacts/sbom --syft <syft v1.54.0> + dotnet run eng/release.cs -- --sbom <version> --syft <syft v1.54.0> ``` - and copy `artifacts/sbom/sbom.txt`. The CLI binaries add `--binary - <rid>` each, once they are released (#129); + which runs `eng/sbom.cs` created at the current UTC day, writes its + `sbom.txt` into the block and that day into `date:` (rule 3, amended + 2026-11-10), and regenerates `CHANGELOG.md`. The CLI binaries add + `--binary <rid>` each, once they are released (#129). Run it again + whenever the digests change, a merge of `main` included; - **checked three times:** - `eng/release.cs` requires format 2 at a commit that carries `eng/sbom.cs`, with a line for every packable project and the image; From cdf0527d1fefa0ce77b228ec9081c6b34eb96f80 Mon Sep 17 00:00:00 2001 From: "Claude (Emil)" <claude+emil@okkels-klein.dk> Date: Tue, 10 Nov 2026 13:42:45 +0000 Subject: [PATCH 10/17] docs(traceability): the record for #248, the descriptor's date follows its digests Closes #248 Co-Authored-By: Claude <noreply@anthropic.com> Signed-off-by: Emil Okkels Klein <emil@okkels-klein.dk> --- ...the-descriptor-date-follows-the-digests.md | 74 +++++++++++++++++++ 1 file changed, 74 insertions(+) create mode 100644 docs/traceability/2026-11-10-issue-248-the-descriptor-date-follows-the-digests.md diff --git a/docs/traceability/2026-11-10-issue-248-the-descriptor-date-follows-the-digests.md b/docs/traceability/2026-11-10-issue-248-the-descriptor-date-follows-the-digests.md new file mode 100644 index 00000000..0cf594d9 --- /dev/null +++ b/docs/traceability/2026-11-10-issue-248-the-descriptor-date-follows-the-digests.md @@ -0,0 +1,74 @@ +# The descriptor's date follows its digests + +> **Recorded contemporaneously**, by the session that did the work, under +> [ADR 0033](../adr/0033-commit-traceability.md). The prompts are verbatim. The +> transcript itself is held by the maintainer. + +| | | +|---|---| +| **Issues** | [#248](https://github.com/Hafeok/Varve/issues/248); [#209](https://github.com/Hafeok/Varve/issues/209) referenced | +| **Date** | 2026-11-10 | +| **Tool** | Claude Code 2.1.322, a cloud session started from the desktop app. Not the maintainer's delegate (ADR 0239 point 1) | +| **Model** | *Left for the maintainer.* The session's metadata reports the model, but this tool's configuration keeps model identifiers out of every file it pushes, so the maintainer fills it in from the session's metadata (ADR 0033, ADR 0241 point 3). | +| **Session identifier** | `session_01NbWSYQ7rmZCcSgsvBFhZMm` | +| **Branch** | `claude/dazzling-darwin-8irixp-step3`, on step 2's branch at 4051685 | +| **ADRs** | [0102](../adr/0102-a-release-is-a-descriptor.md) (amended 2026-11-10), [0224](../adr/0224-supply-chain-transparency.md) (untouched) | + +## The prompts + +### The maintainer, in the session + +> Add a step 3: #248, the descriptor date set at landing, and where the SBOM creation timestamp comes from. Report your plan for all three steps before writing code. + +> You were right about #248: its premise was wrong and the issue is corrected. The ruling is on the issue now: one field, release.cs sets date: whenever it rewrites the sbom: block, land.cs refuses a date more than three days old, a dated amendment to docs/releases.md rule 3, and ADR 0224 untouched. No sbom-created: field. + +### The session prompt, step 3, as attached + +> ## Step 3 — #248: the descriptor date follows the digests +> +> Implement #248 as corrected on 2026-11-10. Its first version had a wrong premise (it said the SBOM creation time departs from ADR 0224; it doesn't, D3 stands). The plan on the issue is the ruling: +> - one field, no `sbom-created:`, and ADR 0224 untouched; +> - `eng/release.cs` sets `date:` to the current UTC day whenever it rewrites the `sbom:` block, and regenerates `CHANGELOG.md`; +> - `eng/land.cs -- N` refuses a descriptor dated more than three days before the landing day, and prints the command that rewrites the digests; +> - a dated amendment to `docs/releases.md` rule 3 (from ADR 0102); +> - the gates listed on the issue. +> +> Commit trailer: `Closes #248`. + +## The report + +- **Where the SBOM creation time comes from**, found before the ruling: + `publish.yml` and `ci.yml` pass the descriptor's `date` as `--created`, + which is what ADR 0224 point 2 (D3) decides. The first version of #248 was + wrong about that, and the maintainer corrected it. +- **Nothing rewrote the `sbom:` block before**: the digests were pasted from + `sbom.txt` by hand. So the rewrite is a new command, + `eng/release.cs -- --sbom <version>`. It runs `eng/sbom.cs` created at the + current UTC day, replaces the block and sets `date:` in one edit, and + regenerates `CHANGELOG.md`. + - `--from <dir>` takes SBOMs already written, and refuses them unless every + one was created on that day. + - `--draft` points at the command. +- **`eng/land.cs`** refuses, at both steps, a descriptor the head adds or + changes whose `date:` is more than three days before the landing day + (UTC), or that has none, and prints the `--sbom` command. +- **Two changes to the posted plan, recorded on #248:** + - The branch is stacked on #209's, because both add `land.cs` fixtures and + one runner serves both. + - ADR 0102 gets a dated amendment beside `docs/releases.md` rule 3, because + rule 3 restates 0102's line on `date`. It adds detail and changes no + ruling (ADR 0068 point 2). ADR 0224 is untouched. +- **Gates:** + - `tests/fixtures/release-sbom/`: four cases, 0 judged wrong. The block and + the date move together; a draft's placeholder is replaced; SBOMs created + on another day are refused; a format-1 descriptor is refused. + - `tests/fixtures/land/`: three date cases joined. Four days is refused, + three days passes, and no date is refused. That makes 12 cases, 0 judged + wrong. + - A `ci.yml` step on Linux rewrites the newest format-2 descriptor, renamed + and backdated, in a scratch directory. It requires `date:` to be today, + every SBOM created at today 00:00Z, and `eng/sbom.cs --descriptor` to + match. Run locally: 29 SBOMs created at 2026-11-10T00:00:00Z, and "sbom + lines match the 29 generated SBOM(s)". Checked against 2026-11-09 + instead, every line differed and the exit was 1. +- **Preview.18 keeps 2026-10-27**: it is cut, and nothing touches it. From 11c35945ba303aaeba515aac3b930c6d5c38e1e5 Mon Sep 17 00:00:00 2001 From: "Claude (Emil)" <claude+emil@okkels-klein.dk> Date: Tue, 10 Nov 2026 13:43:45 +0000 Subject: [PATCH 11/17] docs(sessions): the close-out of the "issues are the work log" session The three pull requests and their order, ADR 0241 and ADR 0102's amendment, the issues opened, closed and touched, the fixture counts, the questions for the maintainer, and #258 deferred. Refs #257 Refs #209 Refs #248 Co-Authored-By: Claude <noreply@anthropic.com> Signed-off-by: Emil Okkels Klein <emil@okkels-klein.dk> --- .../2026-11-issues-are-the-work-log.md | 69 +++++++++++++++++++ 1 file changed, 69 insertions(+) create mode 100644 docs/sessions/2026-11-issues-are-the-work-log.md diff --git a/docs/sessions/2026-11-issues-are-the-work-log.md b/docs/sessions/2026-11-issues-are-the-work-log.md new file mode 100644 index 00000000..177d0bd8 --- /dev/null +++ b/docs/sessions/2026-11-issues-are-the-work-log.md @@ -0,0 +1,69 @@ +# Session, 2026-11-10: issues are the work log + +Developed with AI assistance under human review. The full records, with the +prompts, are one per issue: +- [`2026-11-10-issue-257-issues-are-the-work-log.md`](../traceability/2026-11-10-issue-257-issues-are-the-work-log.md); +- [`2026-11-10-issue-209-closing-trailers-at-landing.md`](../traceability/2026-11-10-issue-209-closing-trailers-at-landing.md); +- [`2026-11-10-issue-248-the-descriptor-date-follows-the-digests.md`](../traceability/2026-11-10-issue-248-the-descriptor-date-follows-the-digests.md). + +A cloud session, not the maintainer's delegate (ADR 0239 point 1). It filed +its decisions unaccepted, posted each head, and asked; it approved, landed +and accepted nothing. + +## Pull requests + +| PR | Step | Closes | +|---|---|---| +| [#259](https://github.com/Hafeok/Varve/pull/259) | 1: `AGENTS.md`'s *Issues are the work log*; ADR 0241, a commit message never names a model and its identifier is allowed | #257 (`Refs #169`) | +| [#260](https://github.com/Hafeok/Varve/pull/260), stacked on #259 | 2: `land.cs` checks closing trailers before a push and after `main` moves; the `Completes:` line | #209 | +| [#261](https://github.com/Hafeok/Varve/pull/261), stacked on #260 | 3: `release.cs --sbom` writes the digests and the date together; `land.cs` refuses a date over three days old | #248 | + +They land in that order. + +## ADRs + +- **0241**, *A commit message never names a model; a model's identifier is + allowed*, filed unaccepted. It supersedes one ruling of 0033, + `ModelNamedInTheRecordNotTheCommit`, which moves to 0241's set under its + key. A reversal is a supersession, not an amendment (ADR 0068 point 2), so + the first plan, an amendment to 0033, was corrected on #257. +- **0102**, amended 2026-11-10, add-only, by #248: `date` is the day the + digests were last written. No ruling changes. ADR 0224 is untouched. + +## Issues + +- **Opened:** + - [#257](https://github.com/Hafeok/Varve/issues/257), the section, since no + issue covered it; + - [#258](https://github.com/Hafeok/Varve/issues/258), the decision report's + baseline is stale on `main`. +- **Closed on landing:** #257, #209 and #248, each by a trailer line. +- **Touched in part:** #169. `AGENTS.md` states its ruling once #259 lands; + its gate, rule page and port stay with it. The coordination is on #169. + +## Test counts + +| Suite | Before | After | +|---|---:|---:| +| `tests/fixtures/land/` (`land-fixtures`) | — | 12 cases | +| `tests/fixtures/release-sbom/` (`release-sbom-fixtures`) | — | 4 cases | +| `ci.yml`: a descriptor's digests rewritten on a later day | — | 1 step, Linux | + +No .NET test project changed. The Release build has zero warnings. + +## Questions + +- **Accept ADR 0241's key** `ModelNamedInTheRecordNotTheCommit`. Nothing in + code cites it, so #259 is not red on `CS0618`; it waits on acceptance all + the same. +- **Every open pull request needs a `Completes:` line** before `land.cs` lands + it, once #260 is on `main`. Today that is #253, #252, #247, #242, #238 and + #236. +- **The records leave the model for the maintainer.** The tool's configuration + keeps model identifiers out of every file it pushes, so ADR 0033's provision + applies. The identifier is in the session's metadata. + +## Deferred + +- [#258](https://github.com/Hafeok/Varve/issues/258): regenerate the decision + report's baseline in a change of its own. From 3da8929ca5f780fd5e3ef2c10bad76236c5b3e18 Mon Sep 17 00:00:00 2001 From: Emil Okkels Klein <emil@okkels-klein.dk> Date: Wed, 11 Nov 2026 13:08:22 +0100 Subject: [PATCH 12/17] docs(decisions): accept ADR 0241's ruling, model names refused and identifiers allowed The maintainer read ADR 0241 on #259 and directed it accepted on 2026-11-11. ModelNamedInTheRecordNotTheCommit, as this ADR restates it, gains accepted-by and accepted-at, its statement unchanged, and the ADR's Status line opens Accepted, as eng/release.cs reads it. Written by his delegate at his word (ADR 0239), on the top of the three pull requests that land together. Refs #257 Delegated-by: Emil Okkels Klein <emil@okkels-klein.dk> Co-Authored-By: Claude <noreply@anthropic.com> Signed-off-by: Emil Okkels Klein <emil@okkels-klein.dk> --- docs/adr/0241-model-names-refused-identifiers-allowed.md | 4 +++- docs/decisions/model-names-refused-identifiers-allowed.md | 5 ++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/docs/adr/0241-model-names-refused-identifiers-allowed.md b/docs/adr/0241-model-names-refused-identifiers-allowed.md index d9122750..e48881cc 100644 --- a/docs/adr/0241-model-names-refused-identifiers-allowed.md +++ b/docs/adr/0241-model-names-refused-identifiers-allowed.md @@ -2,7 +2,9 @@ ## Status -**Filed unaccepted by the session for #257, 2026-11-10** (ADR 0066). Decided +**Accepted — filed unaccepted by the session for #257, 2026-11-10** (ADR 0066), +its ruling accepted on #259 on 2026-11-11, at the maintainer's word, by his +delegate (ADR 0239). Decided by the maintainer on 2026-10-27, in the scope of [#169](https://github.com/Hafeok/Varve/issues/169). Acceptance is the maintainer's act, or his delegate's for a decision he has read (ADR 0239). diff --git a/docs/decisions/model-names-refused-identifiers-allowed.md b/docs/decisions/model-names-refused-identifiers-allowed.md index fd27d7d6..abfff3c7 100644 --- a/docs/decisions/model-names-refused-identifiers-allowed.md +++ b/docs/decisions/model-names-refused-identifiers-allowed.md @@ -5,9 +5,12 @@ adr: 0241 decisions: - key: ModelNamedInTheRecordNotTheCommit statement: "The traceability record names the model by the identifier the session's metadata reports, filled in by the maintainer where the tool will not write it, and a commit message never carries a model's name, while a model's API identifier and the bare product name are allowed" + accepted-by: mailto:emil@okkels-klein.dk + accepted-at: 2026-11-11T00:00:00Z --- The ruling of [ADR 0241](../adr/0241-model-names-refused-identifiers-allowed.md), -filed unaccepted by the session for #257 (ADR 0066). It supersedes the ruling of +filed unaccepted by the session for #257 (ADR 0066) and accepted on #259 on +2026-11-11, at the maintainer's word, by his delegate (ADR 0239). It supersedes the ruling of ADR 0033's amendment of 2026-10-02 under the same key, which moved here from `commit-traceability` (ADR 0068 point 4). From 20e106a1309254cc1fba753cde4171f0fd68467d Mon Sep 17 00:00:00 2001 From: "Claude (Emil)" <claude+emil@okkels-klein.dk> Date: Wed, 11 Nov 2026 12:35:43 +0000 Subject: [PATCH 13/17] feat(eng): the model-names gate, a commit message never names a model eng/model-names.cs holds ADR 0241's pattern once, with the families in eng/model-families.txt: a family word, optionally after the product name, followed by a version, matched regardless of case and only standing alone, so the hyphenated API identifier and the bare product name pass. Over a range it checks every commit a pull request or a land/ push adds over main, merges included, after 2cad18d5, the last commit on main that names a model; each finding names the commit and the line. --message is the hook's mode, and --fixtures runs thirteen cases under tests/fixtures/model-names/, with fictional versions. eng/ci.cs runs both as model-names and model-names-fixtures, and ci.yml's release call skips the range gate as it skips the others. Refs #169 Co-Authored-By: Claude <noreply@anthropic.com> Signed-off-by: Emil Okkels Klein <emil@okkels-klein.dk> --- .github/workflows/ci.yml | 2 +- eng/ci.cs | 8 + eng/model-families.txt | 10 + eng/model-names.cs | 345 ++++++++++++++++++ tests/fixtures/model-names/README.md | 27 ++ .../model-names/fail-name-alone-in-body.txt | 5 + .../fail-name-in-another-trailer.txt | 8 + .../fail-name-in-co-author-trailer.txt | 8 + .../model-names/fail-name-in-subject.txt | 3 + .../model-names/fail-name-lower-case.txt | 5 + .../fail-name-with-claude-in-body.txt | 5 + .../fixtures/model-names/pass-bare-claude.txt | 8 + .../pass-family-word-without-version.txt | 5 + .../fixtures/model-names/pass-identifier.txt | 5 + .../pass-version-without-family.txt | 3 + .../rewrite-co-author-trailer/expected.txt | 8 + .../rewrite-co-author-trailer/input.txt | 8 + .../rewrite-folds-a-repeat/expected.txt | 8 + .../rewrite-folds-a-repeat/input.txt | 9 + .../rewrite-leaves-comments/expected.txt | 8 + .../rewrite-leaves-comments/input.txt | 8 + 21 files changed, 495 insertions(+), 1 deletion(-) create mode 100644 eng/model-families.txt create mode 100644 eng/model-names.cs create mode 100644 tests/fixtures/model-names/README.md create mode 100644 tests/fixtures/model-names/fail-name-alone-in-body.txt create mode 100644 tests/fixtures/model-names/fail-name-in-another-trailer.txt create mode 100644 tests/fixtures/model-names/fail-name-in-co-author-trailer.txt create mode 100644 tests/fixtures/model-names/fail-name-in-subject.txt create mode 100644 tests/fixtures/model-names/fail-name-lower-case.txt create mode 100644 tests/fixtures/model-names/fail-name-with-claude-in-body.txt create mode 100644 tests/fixtures/model-names/pass-bare-claude.txt create mode 100644 tests/fixtures/model-names/pass-family-word-without-version.txt create mode 100644 tests/fixtures/model-names/pass-identifier.txt create mode 100644 tests/fixtures/model-names/pass-version-without-family.txt create mode 100644 tests/fixtures/model-names/rewrite-co-author-trailer/expected.txt create mode 100644 tests/fixtures/model-names/rewrite-co-author-trailer/input.txt create mode 100644 tests/fixtures/model-names/rewrite-folds-a-repeat/expected.txt create mode 100644 tests/fixtures/model-names/rewrite-folds-a-repeat/input.txt create mode 100644 tests/fixtures/model-names/rewrite-leaves-comments/expected.txt create mode 100644 tests/fixtures/model-names/rewrite-leaves-comments/input.txt diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9f5726a0..fdb94511 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -81,7 +81,7 @@ jobs: # Called for a release, the jobs that judge a range rather than a # tree are skipped: the range under release landed through them # already, and its base is not this event's (ADR 0102). - runCmd: dotnet run eng/ci.cs ${{ inputs.ref && '-- --skip issue-refs --skip dco --skip release-pending --skip release' || '' }} + runCmd: dotnet run eng/ci.cs ${{ inputs.ref && '-- --skip issue-refs --skip dco --skip model-names --skip release-pending --skip release' || '' }} # ADR 0102: a change that closes a milestone issue carries the release # descriptor whose basis names it. Also a job in eng/ci.cs; a job of its own diff --git a/eng/ci.cs b/eng/ci.cs index 71935b2b..0782754c 100644 --- a/eng/ci.cs +++ b/eng/ci.cs @@ -105,6 +105,14 @@ ("dco", "every commit is signed off by its author, or by the human responsible for its agent", () => Run("dotnet", Args("run", "eng/dco.cs", baseRef is null ? null : "--", baseRef is null ? null : "--base", baseRef))), + // ADR 0241, #169: the commits a head adds over main, after 2cad18d5, name + // no model; the commit-msg hook rewrites or refuses when the commit is + // made, and this is the backstop. Its failure path runs on every build. + ("model-names", "no commit names a model; a model's identifier is allowed", + () => Run("dotnet", Args("run", "eng/model-names.cs", baseRef is null ? null : "--", baseRef is null ? null : "--base", baseRef))), + ("model-names-fixtures", "the model-names check refuses a name in the subject, the body or a trailer, rewrites a co-author trailer, and allows an identifier", + () => Run("dotnet", ["run", "eng/model-names.cs", "--", "--fixtures", "tests/fixtures/model-names"])), + // The gate's failure path, proven on every run: the failing fixture must // exit 1 and the passing one 0 (tests/fixtures/dco/README.md). ("dco-fixture", "the DCO gate fails its failing fixture and passes its passing one", diff --git a/eng/model-families.txt b/eng/model-families.txt new file mode 100644 index 00000000..a93adc97 --- /dev/null +++ b/eng/model-families.txt @@ -0,0 +1,10 @@ +# The model families a commit message may not name with a version (ADR 0241, +# #169). One word per line. eng/model-names.cs reads this file, and both the +# commit-msg hook and the gate run that script, so the pattern lives here once. +# A name is one of these words, optionally after "Claude", followed by a +# version; the API identifier, hyphenated, and the bare "Claude" are allowed. +Opus +Sonnet +Haiku +Fable +Mythos diff --git a/eng/model-names.cs b/eng/model-names.cs new file mode 100644 index 00000000..28baca92 --- /dev/null +++ b/eng/model-names.cs @@ -0,0 +1,345 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +// A commit message never names a model; a model's identifier is allowed +// (ADR 0241, #169). +// +// dotnet run eng/model-names.cs [-- --base <ref> | --range <a..b>] +// dotnet run eng/model-names.cs -- --message <file> +// dotnet run eng/model-names.cs -- --fixtures tests/fixtures/model-names +// +// A name is a family word from eng/model-families.txt, optionally after +// "Claude", followed by a version: "Opus 5.5", "Claude Sonnet 4". It is matched +// regardless of case, and only standing alone, so the API identifier +// (claude-<family>-<n>-<n>, hyphenated) and the bare "Claude" are not names. +// This file and eng/model-families.txt hold the pattern once: the commit-msg +// hook and the gate both run this script. +// +// --- the range (eng/ci.cs, every pull request and land/ push) ---------------- +// +// Every commit the range adds, merges included, since a merge's message lands +// on main too. The range is worked out as eng/issue-refs.cs works it out: a +// pull request's GITHUB_BASE_REF, a push to main's before, otherwise +// origin/main..HEAD. Commits reachable from 2cad18d5, the last commit on main +// that names a model, are left out: ADR 0241 point 4 leaves history as it is +// (#169, the delegate's comment of 2026-11-11). Each finding names the commit +// and the line. +// +// --- --message (eng/hooks/commit-msg) ------------------------------------------ +// +// The message being committed. A Co-Authored-By: trailer that names a model is +// rewritten to the allowed one, `Co-Authored-By: Claude +// <noreply@anthropic.com>`, and a repeat of it is folded. A name anywhere else +// is refused with the line quoted: the hook cannot know what the author meant. +// Comment lines, and everything below a scissors line, are not the message. +// +// --- --fixtures ------------------------------------------------------------------- +// +// tests/fixtures/model-names/: a pass-*.txt message must be accepted unchanged, +// a fail-*.txt one refused, and each rewrite-* directory's input.txt must be +// rewritten to its expected.txt and then accepted. +// +// Exit codes: 0 conformant (or rewritten), 1 a name, 2 could not run. + +// CA2266 is off for this file only, for the reason eng/dco.cs gives (ADR 0087). +#:property NoWarn=$(NoWarn);CA2266 + +using System.Diagnostics; +using System.Text.RegularExpressions; + +const string LowerBound = "2cad18d53eac54e4b485ad7161916b9f17d2259b"; + +string root = FindRepositoryRoot(); +string? baseRef = null; +string? explicitRange = null; +string? message = null; +string? fixtures = null; + +for (int i = 0; i < args.Length; i++) +{ + switch (args[i]) + { + case "--base" when i + 1 < args.Length: + baseRef = args[++i]; + break; + case "--range" when i + 1 < args.Length: + explicitRange = args[++i]; + break; + case "--message" when i + 1 < args.Length: + message = args[++i]; + break; + case "--fixtures" when i + 1 < args.Length: + fixtures = args[++i]; + break; + default: + Console.Error.WriteLine($"model-names: '{args[i]}' is not an option (see the header of eng/model-names.cs)."); + return 2; + } +} + +ModelNames names; +try +{ + names = ModelNames.Load(Path.Combine(root, "eng", "model-families.txt")); +} +catch (IOException exception) +{ + Console.Error.WriteLine($"model-names: {exception.Message}"); + return 2; +} + +if (fixtures is not null) +{ + return Fixtures(names, Path.GetFullPath(fixtures)); +} + +if (message is not null) +{ + string text = File.ReadAllText(message); + (string rewritten, List<string> refused) = names.Rewrite(text); + if (refused.Count > 0) + { + foreach (string line in refused) + { + Console.Error.WriteLine($"model-names: the message names a model: \"{line.Trim()}\""); + } + + Console.Error.WriteLine("model-names: a commit message never names a model; its identifier is allowed (ADR 0241). Reword the line, or use the identifier."); + return 1; + } + + if (rewritten != text) + { + File.WriteAllText(message, rewritten); + Console.Error.WriteLine($"model-names: a co-author trailer named a model; it now reads `{ModelNames.AllowedTrailer}` (ADR 0241)."); + } + + return 0; +} + +string range = explicitRange ?? ResolveRange(root, baseRef); +List<string> log = ["log", "--format=%x01%H%x02%B", range]; +if (Git(root, "rev-parse", "--verify", "--quiet", LowerBound + "^{commit}").Exit == 0) +{ + log.Add("^" + LowerBound); +} + +Console.WriteLine($"note range: {range}, after {LowerBound[..8]}"); +(int exit, string output, string error) = Git(root, [.. log]); +if (exit != 0) +{ + Console.Error.WriteLine($"model-names: could not read '{range}': {error.Trim()}. In a shallow clone the base may be missing; fetch it, or pass --range."); + return 2; +} + +int commits = 0; +List<string> findings = []; +foreach (string record in output.Split('\u0001', StringSplitOptions.RemoveEmptyEntries)) +{ + int split = record.IndexOf('\u0002', StringComparison.Ordinal); + if (split < 0) + { + continue; + } + + commits++; + string sha = record[..split]; + foreach (string line in names.Named(record[(split + 1)..])) + { + findings.Add($"{sha[..8]}: \"{line.Trim()}\""); + } +} + +if (findings.Count == 0) +{ + Console.WriteLine($"model-names: {commits} commit(s), none names a model."); + return 0; +} + +Console.Error.WriteLine($"FAIL: {findings.Count} line(s) name a model (ADR 0241: a commit message never names a model; its identifier is allowed):"); +foreach (string finding in findings) +{ + Console.Error.WriteLine($" {finding}"); +} + +Console.Error.WriteLine("model-names: eng/hooks/commit-msg rewrites a co-author trailer and refuses the rest when the commit is made; `git config core.hooksPath eng/hooks` sets it. Reword the commits, then push again."); +return 1; + +static int Fixtures(ModelNames names, string directory) +{ + int cases = 0; + int wrong = 0; + foreach (string file in Directory.GetFiles(directory, "*.txt").Order(StringComparer.Ordinal)) + { + cases++; + string name = Path.GetFileName(file); + string text = File.ReadAllText(file); + (string rewritten, List<string> refused) = names.Rewrite(text); + bool passes = refused.Count == 0 && rewritten == text && names.Named(text).Count == 0; + bool right = passes == name.StartsWith("pass-", StringComparison.Ordinal); + wrong += right ? 0 : 1; + Console.WriteLine((right ? "ok " : "FAIL ") + name + ": " + (passes ? "accepted" : refused.Count > 0 ? "refused: " + refused[0].Trim() : "named in a trailer the hook rewrites")); + } + + foreach (string folder in Directory.GetDirectories(directory, "rewrite-*").Order(StringComparer.Ordinal)) + { + cases++; + (string rewritten, List<string> refused) = names.Rewrite(File.ReadAllText(Path.Combine(folder, "input.txt"))); + string expected = File.ReadAllText(Path.Combine(folder, "expected.txt")); + // As committed: git strips the comment lines the hook leaves alone. + string committed = string.Join('\n', rewritten.Split('\n').Where(static line => !line.StartsWith('#'))); + bool right = refused.Count == 0 && rewritten == expected && names.Named(committed).Count == 0; + wrong += right ? 0 : 1; + Console.WriteLine((right ? "ok " : "FAIL ") + Path.GetFileName(folder) + ": " + (right ? "rewritten as expected" : "rewritten to:\n" + rewritten)); + } + + Console.WriteLine(cases + " cases, " + wrong + " judged wrong"); + return cases > 0 && wrong == 0 ? 0 : 1; +} + +static string ResolveRange(string root, string? baseRef) +{ + string head = Environment.GetEnvironmentVariable("GITHUB_SHA") ?? "HEAD"; + if (baseRef is not null) + { + return $"{baseRef}..{head}"; + } + + string? pullRequestBase = Environment.GetEnvironmentVariable("GITHUB_BASE_REF"); + if (!string.IsNullOrEmpty(pullRequestBase)) + { + return $"origin/{pullRequestBase}..{head}"; + } + + string? before = Environment.GetEnvironmentVariable("GITHUB_EVENT_BEFORE"); + bool toMain = Environment.GetEnvironmentVariable("GITHUB_REF") == "refs/heads/main"; + if (toMain && !string.IsNullOrEmpty(before) && before.Trim('0').Length > 0 + && Git(root, "rev-parse", "--verify", "--quiet", before + "^{commit}").Exit == 0) + { + return $"{before}..{head}"; + } + + return Git(root, "rev-parse", "--verify", "--quiet", "origin/main^{commit}").Exit == 0 ? $"origin/main..{head}" : $"main..{head}"; +} + +static (int Exit, string Output, string Error) Git(string root, params string[] arguments) +{ + ProcessStartInfo start = new() + { + FileName = "git", + WorkingDirectory = root, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + }; + + foreach (string argument in arguments) + { + start.ArgumentList.Add(argument); + } + + using Process process = Process.Start(start) ?? throw new InvalidOperationException("could not start git"); + Task<string> error = process.StandardError.ReadToEndAsync(); + string output = process.StandardOutput.ReadToEnd(); + process.WaitForExit(); + return (process.ExitCode, output, error.Result); +} + +static string FindRepositoryRoot() +{ + DirectoryInfo? directory = new(Directory.GetCurrentDirectory()); + while (directory is not null) + { + if (File.Exists(Path.Combine(directory.FullName, "Varve.slnx"))) + { + return directory.FullName; + } + + directory = directory.Parent; + } + + throw new InvalidOperationException("Could not find the repository root (no Varve.slnx above the current directory)."); +} + +// The pattern, and what the hook and the gate do with it. +sealed class ModelNames(Regex name) +{ + public const string AllowedTrailer = "Co-Authored-By: Claude <noreply@anthropic.com>"; + + static readonly Regex CoAuthor = new(@"^\s*Co-Authored-By:", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); + + public static ModelNames Load(string path) + { + string[] families = [.. File.ReadAllLines(path) + .Select(static line => line.Trim()) + .Where(static line => line.Length > 0 && !line.StartsWith('#')) + .Select(Regex.Escape)]; + if (families.Length == 0) + { + throw new IOException($"{path} lists no model family"); + } + + // Standing alone: no word character or hyphen on either side, so the + // hyphenated API identifier never matches. + return new ModelNames(new Regex( + $@"(?<![\w-])(?:Claude\s+)?(?:{string.Join('|', families)})\s+v?\d+(?:\.\d+)*(?![\w-]|\.\d)", + RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)); + } + + // The lines of a committed message that name a model. + public List<string> Named(string message) => + [.. message.Split('\n').Select(static line => line.TrimEnd('\r')).Where(line => name.IsMatch(line))]; + + // The hook: co-author trailers that name a model become the allowed one, + // and any other line that names one is refused. Comment lines and what + // follows a scissors line are not part of the message. + public (string Rewritten, List<string> Refused) Rewrite(string message) + { + List<string> output = []; + List<string> refused = []; + bool allowedSeen = false; + bool below = false; + foreach (string raw in message.Split('\n')) + { + string line = raw.TrimEnd('\r'); + if (below || line.StartsWith('#')) + { + below |= line.StartsWith("# ------------------------ >8 ------------------------", StringComparison.Ordinal); + output.Add(raw); + continue; + } + + bool coAuthor = CoAuthor.IsMatch(line); + if (coAuthor && name.IsMatch(line)) + { + if (!allowedSeen) + { + output.Add(AllowedTrailer); + allowedSeen = true; + } + + continue; + } + + if (name.IsMatch(line)) + { + refused.Add(line); + } + + if (coAuthor && line.Trim().Equals(AllowedTrailer, StringComparison.OrdinalIgnoreCase)) + { + if (allowedSeen) + { + continue; + } + + allowedSeen = true; + } + + output.Add(raw); + } + + return (string.Join('\n', output), refused); + } +} diff --git a/tests/fixtures/model-names/README.md b/tests/fixtures/model-names/README.md new file mode 100644 index 00000000..9fd9c0f8 --- /dev/null +++ b/tests/fixtures/model-names/README.md @@ -0,0 +1,27 @@ +# `eng/model-names.cs`'s fixtures + +Cases for `eng/model-names.cs --fixtures` (ADR 0241, #169). A `pass-*.txt` +message must be accepted as written; a `fail-*.txt` one must be refused, by the +hook (`--message`) or, for a co-author trailer the hook would rewrite, by the +gate over a range. Each `rewrite-*` directory's `input.txt` must become its +`expected.txt` under `--message`, and the result must pass the gate. The +pipeline's `model-names-fixtures` job runs them on every build. + +The versions are fictional (9 and 9.9): the pattern matches any version, and +no real model's identifier is kept in the repository. + +| Case | Why | +|---|---| +| `fail-name-alone-in-body` | a family word and a version, in the body | +| `fail-name-with-claude-in-body` | the same after the product name | +| `fail-name-in-subject` | in the subject | +| `fail-name-in-another-trailer` | in a trailer the hook does not rewrite | +| `fail-name-in-co-author-trailer` | in a co-author trailer: the hook rewrites it, the gate refuses it as committed | +| `fail-name-lower-case` | case does not matter | +| `pass-identifier` | the API identifier, hyphenated, is allowed | +| `pass-bare-claude` | the allowed co-author trailer | +| `pass-family-word-without-version` | a family word with no version is not a name | +| `pass-version-without-family` | a version with no family word is not a name | +| `rewrite-co-author-trailer` | the trailer becomes the allowed one | +| `rewrite-folds-a-repeat` | a rewritten trailer and an existing allowed one fold into one | +| `rewrite-leaves-comments` | comment lines are not the message and stay as they are | diff --git a/tests/fixtures/model-names/fail-name-alone-in-body.txt b/tests/fixtures/model-names/fail-name-alone-in-body.txt new file mode 100644 index 00000000..6ab4c329 --- /dev/null +++ b/tests/fixtures/model-names/fail-name-alone-in-body.txt @@ -0,0 +1,5 @@ +fix(store): a change + +Written with Opus 9.9, then reviewed. + +Refs #1 diff --git a/tests/fixtures/model-names/fail-name-in-another-trailer.txt b/tests/fixtures/model-names/fail-name-in-another-trailer.txt new file mode 100644 index 00000000..2ff546e5 --- /dev/null +++ b/tests/fixtures/model-names/fail-name-in-another-trailer.txt @@ -0,0 +1,8 @@ +fix(store): a change + +What it does. + +Refs #1 + +Assisted-by: Claude Fable 9.9 +Signed-off-by: A Person <person@example.org> diff --git a/tests/fixtures/model-names/fail-name-in-co-author-trailer.txt b/tests/fixtures/model-names/fail-name-in-co-author-trailer.txt new file mode 100644 index 00000000..fa7dd741 --- /dev/null +++ b/tests/fixtures/model-names/fail-name-in-co-author-trailer.txt @@ -0,0 +1,8 @@ +fix(store): a change + +What it does. + +Refs #1 + +Co-Authored-By: Claude Opus 9.9 <noreply@anthropic.com> +Signed-off-by: A Person <person@example.org> diff --git a/tests/fixtures/model-names/fail-name-in-subject.txt b/tests/fixtures/model-names/fail-name-in-subject.txt new file mode 100644 index 00000000..a1828269 --- /dev/null +++ b/tests/fixtures/model-names/fail-name-in-subject.txt @@ -0,0 +1,3 @@ +chore: notes from Haiku 9.9 + +Refs #1 diff --git a/tests/fixtures/model-names/fail-name-lower-case.txt b/tests/fixtures/model-names/fail-name-lower-case.txt new file mode 100644 index 00000000..db396b5f --- /dev/null +++ b/tests/fixtures/model-names/fail-name-lower-case.txt @@ -0,0 +1,5 @@ +fix(store): a change + +as opus 9 put it + +Refs #1 diff --git a/tests/fixtures/model-names/fail-name-with-claude-in-body.txt b/tests/fixtures/model-names/fail-name-with-claude-in-body.txt new file mode 100644 index 00000000..6f4a1b4f --- /dev/null +++ b/tests/fixtures/model-names/fail-name-with-claude-in-body.txt @@ -0,0 +1,5 @@ +fix(store): a change + +Claude Sonnet 9 suggested the shape. + +Refs #1 diff --git a/tests/fixtures/model-names/pass-bare-claude.txt b/tests/fixtures/model-names/pass-bare-claude.txt new file mode 100644 index 00000000..77ac61d2 --- /dev/null +++ b/tests/fixtures/model-names/pass-bare-claude.txt @@ -0,0 +1,8 @@ +fix(store): a change + +What it does. + +Refs #1 + +Co-Authored-By: Claude <noreply@anthropic.com> +Signed-off-by: A Person <person@example.org> diff --git a/tests/fixtures/model-names/pass-family-word-without-version.txt b/tests/fixtures/model-names/pass-family-word-without-version.txt new file mode 100644 index 00000000..27e198c9 --- /dev/null +++ b/tests/fixtures/model-names/pass-family-word-without-version.txt @@ -0,0 +1,5 @@ +docs: the haiku in the README + +An opus of a change, in sonnet form. + +Refs #1 diff --git a/tests/fixtures/model-names/pass-identifier.txt b/tests/fixtures/model-names/pass-identifier.txt new file mode 100644 index 00000000..b3aaab4d --- /dev/null +++ b/tests/fixtures/model-names/pass-identifier.txt @@ -0,0 +1,5 @@ +fix(store): a change + +The session ran on claude-opus-9-9, as its record says. + +Refs #1 diff --git a/tests/fixtures/model-names/pass-version-without-family.txt b/tests/fixtures/model-names/pass-version-without-family.txt new file mode 100644 index 00000000..45709af5 --- /dev/null +++ b/tests/fixtures/model-names/pass-version-without-family.txt @@ -0,0 +1,3 @@ +build: SDK 9.9 and Claude Code 9.9 + +Refs #1 diff --git a/tests/fixtures/model-names/rewrite-co-author-trailer/expected.txt b/tests/fixtures/model-names/rewrite-co-author-trailer/expected.txt new file mode 100644 index 00000000..77ac61d2 --- /dev/null +++ b/tests/fixtures/model-names/rewrite-co-author-trailer/expected.txt @@ -0,0 +1,8 @@ +fix(store): a change + +What it does. + +Refs #1 + +Co-Authored-By: Claude <noreply@anthropic.com> +Signed-off-by: A Person <person@example.org> diff --git a/tests/fixtures/model-names/rewrite-co-author-trailer/input.txt b/tests/fixtures/model-names/rewrite-co-author-trailer/input.txt new file mode 100644 index 00000000..fa7dd741 --- /dev/null +++ b/tests/fixtures/model-names/rewrite-co-author-trailer/input.txt @@ -0,0 +1,8 @@ +fix(store): a change + +What it does. + +Refs #1 + +Co-Authored-By: Claude Opus 9.9 <noreply@anthropic.com> +Signed-off-by: A Person <person@example.org> diff --git a/tests/fixtures/model-names/rewrite-folds-a-repeat/expected.txt b/tests/fixtures/model-names/rewrite-folds-a-repeat/expected.txt new file mode 100644 index 00000000..77ac61d2 --- /dev/null +++ b/tests/fixtures/model-names/rewrite-folds-a-repeat/expected.txt @@ -0,0 +1,8 @@ +fix(store): a change + +What it does. + +Refs #1 + +Co-Authored-By: Claude <noreply@anthropic.com> +Signed-off-by: A Person <person@example.org> diff --git a/tests/fixtures/model-names/rewrite-folds-a-repeat/input.txt b/tests/fixtures/model-names/rewrite-folds-a-repeat/input.txt new file mode 100644 index 00000000..f7d8a42d --- /dev/null +++ b/tests/fixtures/model-names/rewrite-folds-a-repeat/input.txt @@ -0,0 +1,9 @@ +fix(store): a change + +What it does. + +Refs #1 + +Co-Authored-By: Claude Sonnet 9 <noreply@anthropic.com> +Co-Authored-By: Claude <noreply@anthropic.com> +Signed-off-by: A Person <person@example.org> diff --git a/tests/fixtures/model-names/rewrite-leaves-comments/expected.txt b/tests/fixtures/model-names/rewrite-leaves-comments/expected.txt new file mode 100644 index 00000000..e1033524 --- /dev/null +++ b/tests/fixtures/model-names/rewrite-leaves-comments/expected.txt @@ -0,0 +1,8 @@ +fix(store): a change + +What it does. + +Refs #1 + +Co-Authored-By: Claude <noreply@anthropic.com> +# Please enter the commit message. Haiku 9.9 here is a comment. diff --git a/tests/fixtures/model-names/rewrite-leaves-comments/input.txt b/tests/fixtures/model-names/rewrite-leaves-comments/input.txt new file mode 100644 index 00000000..0c0db863 --- /dev/null +++ b/tests/fixtures/model-names/rewrite-leaves-comments/input.txt @@ -0,0 +1,8 @@ +fix(store): a change + +What it does. + +Refs #1 + +Co-Authored-By: Claude Haiku 9.9 <noreply@anthropic.com> +# Please enter the commit message. Haiku 9.9 here is a comment. From 8e321fa5116d6b20b5b0c4140c00dbdf9419361e Mon Sep 17 00:00:00 2001 From: "Claude (Emil)" <claude+emil@okkels-klein.dk> Date: Wed, 11 Nov 2026 12:35:43 +0000 Subject: [PATCH 14/17] feat(hooks): commit-msg rewrites a co-author trailer that names a model Before the sign-off, for every author and every tool, the hook runs eng/model-names.cs --message: a Co-Authored-By: trailer that names a model becomes the allowed one, a repeat folds, and a name anywhere else refuses the commit with the line quoted. When the script cannot run, the hook says so and leaves the message to the gate. Refs #169 Co-Authored-By: Claude <noreply@anthropic.com> Signed-off-by: Emil Okkels Klein <emil@okkels-klein.dk> --- eng/hooks/commit-msg | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/eng/hooks/commit-msg b/eng/hooks/commit-msg index 98266d8c..b66e43e7 100755 --- a/eng/hooks/commit-msg +++ b/eng/hooks/commit-msg @@ -3,9 +3,17 @@ # License, v. 2.0. If a copy of the MPL was not distributed with this # file, You can obtain one at https://mozilla.org/MPL/2.0/. # -# The DCO sign-off as eng/dco.cs reads it, made when the commit is made -# (ADR 0034 point 3, ADR 0087). An agent's commit is signed off by its -# responsible human in eng/identities.json, never by the agent: `git commit -s` +# Two things, made when the commit is made, for every author and every tool. +# +# First, a model's name (ADR 0241, #169). eng/model-names.cs holds the pattern +# the gate uses too: a Co-Authored-By: trailer that names a model is rewritten +# to `Co-Authored-By: Claude <noreply@anthropic.com>`, and a name anywhere else +# refuses the commit, with the line named. When the script cannot run (no SDK), +# the hook says so and leaves the message to the gate in CI. +# +# Second, the DCO sign-off as eng/dco.cs reads it (ADR 0034 point 3, ADR +# 0087). An agent's commit is signed off by its responsible human in +# eng/identities.json, never by the agent: `git commit -s` # in a session signs off as the session's own identity, which the gate refuses. # So the hook drops an agent's own sign-off and adds the one the gate accepts. # A human's commit is left as written. @@ -19,6 +27,14 @@ message="$1" root="$(git rev-parse --show-toplevel)" author="$(git var GIT_AUTHOR_IDENT | sed -E 's/^.*<([^>]*)>.*$/\1/')" +status=0 +dotnet run "$root/eng/model-names.cs" -- --message "$message" || status=$? +case "$status" in + 0) ;; + 1) exit 1 ;; + *) echo "commit-msg: eng/model-names.cs could not run (exit $status); the model-names gate checks this commit in CI" >&2 ;; +esac + # eng/dco.cs names the sign-off the gate accepts from this author. For a human # it is their own, and their message is left as they wrote it; an author in no # entry is refused by the gate in CI, with its own hint. From 667d7a9d70a4ce13ca42c77a186eae29542f3e5b Mon Sep 17 00:00:00 2001 From: "Claude (Emil)" <claude+emil@okkels-klein.dk> Date: Wed, 11 Nov 2026 12:35:44 +0000 Subject: [PATCH 15/17] chore(claude): the project's default attribution is the allowed trailer .claude/settings.json sets attribution.commit to the co-author trailer ADR 0241 allows and attribution.pr to the tool's link line, so a Claude Code session in this repository writes them by default. It is one tool's way of meeting the rule; AGENTS.md states the rule. Refs #169 Co-Authored-By: Claude <noreply@anthropic.com> Signed-off-by: Emil Okkels Klein <emil@okkels-klein.dk> --- .claude/settings.json | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 .claude/settings.json diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 00000000..9cc8f0e7 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,7 @@ +{ + "$schema": "https://json.schemastore.org/claude-code-settings.json", + "attribution": { + "commit": "Co-Authored-By: Claude <noreply@anthropic.com>", + "pr": "🤖 Generated with [Claude Code](https://claude.com/claude-code)" + } +} From 0f07964f7205ca7febed4b110da70f3a791fbcd5 Mon Sep 17 00:00:00 2001 From: "Claude (Emil)" <claude+emil@okkels-klein.dk> Date: Wed, 11 Nov 2026 12:35:45 +0000 Subject: [PATCH 16/17] docs: where the model-name rule is held, and its rule page AGENTS.md's Every commit says the hook rewrites or refuses and the gate checks the range, naming no tool's settings file (ADR 0033). docs/rules/ gains a commit-gate section and model-names.md: what it reports, the three places it is held, how to satisfy it, its failure path. Refs #169 Co-Authored-By: Claude <noreply@anthropic.com> Signed-off-by: Emil Okkels Klein <emil@okkels-klein.dk> --- AGENTS.md | 11 ++++++-- docs/rules/README.md | 9 +++++++ docs/rules/model-names.md | 57 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 75 insertions(+), 2 deletions(-) create mode 100644 docs/rules/model-names.md diff --git a/AGENTS.md b/AGENTS.md index f833e0d1..6f173418 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -164,8 +164,15 @@ fills it in: a blank model is an incomplete record. **A commit message never carries a model's name** (a family word, alone or after the product, followed by a version); a model's identifier (the string an API takes to select a model) is allowed, and so is a co-author trailer naming only the product -(ADR 0241, superseding ADR 0033's amendment of 2026-10-02). Prose says -"developed with AI assistance under human review" and **names no product**. +(ADR 0241, superseding ADR 0033's amendment of 2026-10-02). It is held where +the commit is made and again in CI: `eng/hooks/commit-msg` rewrites a +co-author trailer that names a model to `Co-Authored-By: Claude +<noreply@anthropic.com>` and refuses a name anywhere else, and the +`model-names` gate (`eng/model-names.cs`, the families in +`eng/model-families.txt`) checks every commit a head adds over `main` (#169). +A tool's own settings in the repository may set its default attribution to +match; the rule is this one. Prose says "developed with AI assistance under +human review" and **names no product**. ## Issues are the work log diff --git a/docs/rules/README.md b/docs/rules/README.md index 095d9d23..393a4871 100644 --- a/docs/rules/README.md +++ b/docs/rules/README.md @@ -29,3 +29,12 @@ No `VARVE` rule is suppressed. `DD0008` reports a `#pragma`, a `.editorconfig` sets `dd_rule_id_prefixes = VARVE`. The only exception path is `[DesignDecision(typeof(<Set>.<Key>), Scope = …)]` on the symbol, citing a filed decision. + +## Commit-message gates + +Rules about commits, not code, are scripts under `eng/`, run as jobs of +`eng/ci.cs`, each with fixtures that prove its failure path on every build. + +| Gate | Rule | Decision | +|---|---|---| +| [`model-names`](model-names.md) | A commit message never names a model; a model's identifier is allowed | ADR [0241](../adr/0241-model-names-refused-identifiers-allowed.md), superseding one ruling of [0033](../adr/0033-commit-traceability.md) | diff --git a/docs/rules/model-names.md b/docs/rules/model-names.md new file mode 100644 index 00000000..6bc8904c --- /dev/null +++ b/docs/rules/model-names.md @@ -0,0 +1,57 @@ +# `model-names` — a commit message never names a model + +**Decision:** ADR [0241](../adr/0241-model-names-refused-identifiers-allowed.md), +`ModelNamedInTheRecordNotTheCommit`, which supersedes that ruling of ADR +[0033](../adr/0033-commit-traceability.md)'s amendment of 2026-10-02. Built for +[#169](https://github.com/Hafeok/Varve/issues/169). + +## What it reports + +A line of a commit message that names a model: a family word listed in +`eng/model-families.txt`, optionally after `Claude`, followed by a version. +Case does not matter. A finding names the commit and the line. + +Allowed: +- a model's identifier in the API's hyphenated form, `claude-<family>-<n>-<n>`; +- the bare product name, as in `Co-Authored-By: Claude <noreply@anthropic.com>`; +- a family word with no version, and a version with no family word. + +## Where it is held + +Three places, earliest first. The pattern lives once, in +`eng/model-names.cs` and `eng/model-families.txt`, and the hook and the gate +both run that script. + +1. **A tool's default.** `.claude/settings.json` sets one tool's default + co-author trailer to the allowed one. Other tools have their own settings; + `AGENTS.md` states the rule, not a tool's configuration (ADR 0033). +2. **The commit.** `eng/hooks/commit-msg` (`git config core.hooksPath + eng/hooks`) rewrites a `Co-Authored-By:` trailer that names a model to the + allowed one, folding a repeat, and refuses a name anywhere else with the + line quoted, for every author and every tool. When the SDK is missing it + says so and leaves the message to the gate. +3. **The gate.** `dotnet run eng/model-names.cs` checks every commit a pull + request or a `land/` push adds over `main`, merges included, worked out from + the environment as `eng/issue-refs.cs` does. Commits reachable from + `2cad18d5`, the last commit on `main` that names a model, are left out: ADR + 0241 point 4 leaves history as it is. `ci.yml`'s release call skips it, as + it skips the other range gates. + +## How to satisfy it + +Reword the line, or write the identifier instead of the name. A commit already +made is reworded (`git commit --amend`, or a rebase on a branch of your own) +and pushed again; with the hook set, the trailer is fixed as it is made. + +## Its failure path + +`tests/fixtures/model-names/`, run by the `model-names-fixtures` job: a name +alone, after `Claude`, in the subject, in the body, in a trailer the hook does +not rewrite and in lower case, each refused; an identifier, the allowed +trailer, a family word without a version and a version without a family word, +each accepted; and three rewrites by the hook. The versions in the fixtures are +fictional. + +## Exception path + +None. The rule has no suppression; a line that must quote a name is reworded. From 402a4e06b15ba2e74aaf2ed1c64c21616d879387 Mon Sep 17 00:00:00 2001 From: "Claude (Emil)" <claude+emil@okkels-klein.dk> Date: Wed, 11 Nov 2026 12:36:56 +0000 Subject: [PATCH 17/17] docs(traceability): the record for #169, model names refused in three layers Refs #169 Co-Authored-By: Claude <noreply@anthropic.com> Signed-off-by: Emil Okkels Klein <emil@okkels-klein.dk> --- ...1-issue-169-model-names-in-three-layers.md | 56 +++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 docs/traceability/2026-11-11-issue-169-model-names-in-three-layers.md diff --git a/docs/traceability/2026-11-11-issue-169-model-names-in-three-layers.md b/docs/traceability/2026-11-11-issue-169-model-names-in-three-layers.md new file mode 100644 index 00000000..5bc7d3ec --- /dev/null +++ b/docs/traceability/2026-11-11-issue-169-model-names-in-three-layers.md @@ -0,0 +1,56 @@ +# Model names refused in three layers: the tool's default, the hook, the gate + +> **Recorded contemporaneously**, by the session that did the work, under +> [ADR 0033](../adr/0033-commit-traceability.md). The prompt is verbatim. The +> transcript itself is held by the maintainer. + +| | | +|---|---| +| **Issue** | [#169](https://github.com/Hafeok/Varve/issues/169) | +| **Date** | 2026-11-11 | +| **Tool** | Claude Code 2.1.322, a cloud session started from the desktop app. Not the maintainer's delegate (ADR 0239 point 1) | +| **Model** | *Left for the maintainer.* The session's metadata reports the model, but this tool's configuration keeps model identifiers out of every file it pushes, so the maintainer fills it in from the session's metadata (ADR 0033, ADR 0241 point 3). | +| **Session identifier** | `session_01NbWSYQ7rmZCcSgsvBFhZMm` | +| **Branch** | `claude/dazzling-darwin-8irixp-169`, on `3da8929c`, the approved head of #259–#261 | +| **ADRs** | [0241](../adr/0241-model-names-refused-identifiers-allowed.md) (implemented), [0033](../adr/0033-commit-traceability.md) | + +## The prompt + +> Read AGENTS.md, then #169 including the comment of 2026-11-11. Build all three layers in one pull request: the .claude/settings.json attribution, the commit-msg hook rewrite, and the gate. Post your plan on #169 first. Confirm on the issue that a cloud session honours the project-level setting. + +## The report + +- **The plan went on #169 first.** +- **One pattern, held once:** + - `eng/model-names.cs`, with the families in `eng/model-families.txt`; + - a name is a family word, optionally after the product name, followed by a + version, matched regardless of case and only standing alone; + - the hyphenated API identifier, the bare product name, a family word with + no version and a version with no family word all pass. +- **The gate:** + - every commit a pull request or a `land/` push adds over `main`, merges + included, after `2cad18d5`, which is the lower bound in the delegate's + comment; + - the range is worked out as `eng/issue-refs.cs` works it out; + - `model-names` and `model-names-fixtures` in `eng/ci.cs`; + - the release call skips it. +- **The hook:** `eng/hooks/commit-msg` runs the script's `--message` mode + first, for every author. A co-author trailer naming a model is rewritten to + the allowed one, a repeat folds, and a name anywhere else refuses the commit + with the line quoted. When the script cannot run, the hook says so and leaves + the message to the gate. +- **The tool's default:** `.claude/settings.json` sets `attribution.commit` + and `attribution.pr`. `AGENTS.md` states the rule and names no tool's + settings file (ADR 0033). +- **Exercised on scratch commits, not pushed:** + - a trailer naming a model was rewritten; + - a name in the body was refused, exit 1; + - a commit made with `--no-verify` was caught by the gate, exit 1, naming + the commit and the line. +- **Fixtures:** 13 cases, 0 judged wrong. The versions are fictional, so no + real model's identifier is in the repository. +- **Lower bound:** `2cad18d5~4..2cad18d5` reads 0 commits, and + `2cad18d5~4..3da8929c` reads 16 commits, none naming a model, which agrees + with the delegate's count. +- **Not in this change:** the port to the analyzer repository (#65), which is + #169's last box.