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/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6b08cb9e..9f5726a0 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/AGENTS.md b/AGENTS.md index ee8449af..f833e0d1 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 @@ -159,10 +161,51 @@ 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**. + +## 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, 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. + + "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: `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 + replace them. ## `Varve.Store` behaviour @@ -567,8 +610,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/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/adr/0241-model-names-refused-identifiers-allowed.md b/docs/adr/0241-model-names-refused-identifiers-allowed.md new file mode 100644 index 00000000..e48881cc --- /dev/null +++ b/docs/adr/0241-model-names-refused-identifiers-allowed.md @@ -0,0 +1,92 @@ +# 0241 — A commit message never names a model; a model's identifier is allowed + +## Status + +**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). + +**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 509a5332..d6ef930d 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -462,6 +462,14 @@ Filed by [#101](https://github.com/Hafeok/Varve/issues/101), unaccepted until th |---:|---|---| | [0223](0223-provider-legs-by-federated-credentials.md) | The provider legs by federated credentials: GitHub's OIDC token for Entra ID and Google | Filed unaccepted (#101) | +## 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..abfff3c7 --- /dev/null +++ b/docs/decisions/model-names-refused-identifiers-allowed.md @@ -0,0 +1,16 @@ +--- +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" + 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) 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). 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; 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. 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. 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. 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 #<the new issue>` 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. diff --git a/eng/ci.cs b/eng/ci.cs index 25c1c133..71935b2b 100644 --- a/eng/ci.cs +++ b/eng/ci.cs @@ -119,6 +119,15 @@ ("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 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 // 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..597aec78 100644 --- a/eng/land.cs +++ b/eng/land.cs @@ -10,6 +10,7 @@ // // --name <name> the land/ branch's name, in place of the one worked out // --print print the git commands, run none +// --fixtures <dir> 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/<name>, letting CI and `agent review` @@ -35,7 +36,26 @@ // `--main` refuses unless land/<name> 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. +// +// 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). // // See docs/adr/0088-only-checked-commits-reach-main.md. @@ -43,6 +63,7 @@ #:property NoWarn=$(NoWarn);CA2266 using System.Diagnostics; +using System.Text.Json; using System.Text.RegularExpressions; const string Remote = "origin"; @@ -52,6 +73,7 @@ string? name = null; bool toMain = false; bool print = false; +string? fixtures = null; for (int i = 0; i < args.Length; i++) { @@ -69,6 +91,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 +110,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 <rev>, one of them"); @@ -125,6 +155,49 @@ return 1; } + DateOnly landingDay = DateOnly.FromDateTime(DateTime.UtcNow); + List<string> 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<Trailer> 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<string> 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 +245,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<string> 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 +259,95 @@ 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<Trailer> closing) +{ + if (closing.Count == 0) + { + Console.WriteLine("land: the push closes no issue."); + return; + } + + Console.WriteLine("land: the push closes:"); + foreach (IGrouping<int, Trailer> 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<int, string>? IssueStates(List<Trailer> closing) +{ + Dictionary<int, string> 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)); + } +} + +// 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-<version>, when the head adds a descriptor that main does not have. static string? ReleaseName(string main, string head) { @@ -288,6 +456,197 @@ dotnet run eng/land.cs -- <pr> push the pull request's head to land options: --name <name> the land/ branch's name, in place of the one worked out --print print the git commands, run none + --fixtures <dir> 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+(?:(?<repository>[\w.-]+/[\w.-]+))?#(?<issue>\d+)\s*$", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); + + static readonly Regex CompletesLine = new(@"^[ \t]*Completes:[ \t]*(?<value>.*?)[ \t]*$", RegexOptions.Multiline | RegexOptions.CultureInvariant); + + public static List<Trailer> Trailers(IEnumerable<(string Sha, string Message)> commits) + { + List<Trailer> 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<string> CheckCompletes(string body, List<Trailer> 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<int> 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<int> 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<string> Said) CheckClosed(List<Trailer> closing, Dictionary<int, string>? 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<string> said = []; + foreach (IGrouping<int, Trailer> 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); + } +} + +// 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 <syft v1.54.0> --binary <rid>..."; + 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). +// 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 +{ + 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<Trailer> 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<string> said) = step switch + { + "push" => Push(root, closing), + "landed" => Landed(root, closing), + "date" => Date(root), + _ => 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<string>) Push(JsonElement root, List<Trailer> closing) + { + List<string> refusals = Closings.CheckCompletes(root.GetProperty("body").GetString()!, closing); + return (refusals.Count == 0, refusals); + } + + static (bool, List<string>) 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<string>) Landed(JsonElement root, List<Trailer> closing) + { + JsonElement states = root.GetProperty("states"); + Dictionary<int, string>? 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<string> said) = Closings.CheckClosed(closing, read); + return (exit == 0, said); + } +} diff --git a/eng/release.cs b/eng/release.cs index fa787677..56f6bb81 100644 --- a/eng/release.cs +++ b/eng/release.cs @@ -8,6 +8,9 @@ // dotnet run eng/release.cs -- --check [--base <ref>] // dotnet run eng/release.cs -- --plan [--dry-run] [--landing] [--since <sha>] [--fallback <dir>] [--offline] // dotnet run eng/release.cs -- --draft <version> +// dotnet run eng/release.cs -- --sbom <version> [--syft <syft>] [--binary <rid>]... [--image <p>=<json>]... +// [--today <yyyy-mm-dd>] [--from <dir> | --out <dir>] +// dotnet run eng/release.cs -- --sbom-fixtures tests/fixtures/release-sbom // dotnet run eng/release.cs -- --notes <version> --into <dir> // 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 <dir> 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<string> 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 <version>, --notes <version> --into <dir>, --fixtures <dir>."); + Console.Error.WriteLine("release: one of --check, --plan, --draft <version>, --notes <version> --into <dir>, --sbom <version>, --fixtures <dir>, --sbom-fixtures <dir>."); return 2; } @@ -870,10 +913,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 <rid>... --image linux/amd64=<syft.json> --image linux/arm64=<syft.json>\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 <syft v1.54.0> --binary <rid>...\n"); + draft.Append("# publish.yml checks every line before anything is pushed.\n"); draft.Append("sbom:\n"); draft.Append(" - <kind: name sha256:digest, from sbom.txt>\n"); draft.Append("accepted-by: mailto:<the human who approves the pull request>\n"); @@ -992,6 +1035,106 @@ static async Task<int> 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<string> 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<string> 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<string> 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<string> 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()); @@ -1238,3 +1381,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 <directory>/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<string> 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 ` - <kind>: <name> sha256:<digest>`."); + } + + 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/land/README.md b/tests/fixtures/land/README.md new file mode 100644 index 00000000..72360c23 --- /dev/null +++ b/tests/fixtures/land/README.md @@ -0,0 +1,24 @@ +# `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. 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 | +|---|---|---| +| `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 | +| `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-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 <a@example.org>\n" + } + ], + "says": [ + "completes #1, and no pushed commit closes it" + ] +} 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/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<!-- Completes: #N, ... or Completes: none -->\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 <a@example.org>\n" + } + ] +} 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" +} 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" + } +} 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: + - <kind: name sha256:digest, from sbom.txt> +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