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/agent-review.yml b/.github/workflows/agent-review.yml index ecf3ad58..912eb620 100644 --- a/.github/workflows/agent-review.yml +++ b/.github/workflows/agent-review.yml @@ -80,9 +80,12 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: # main, by name, on every path: never the pull request's head or - # merge ref, and never the pushed land/ commit. + # merge ref, and never the pushed land/ commit. On a land/ push the + # whole history is fetched, the pushed commit's included, so that + # git can read a landing train's merges (#277): read as data, never + # checked out, built or run. ref: ${{ github.event.repository.default_branch }} - fetch-depth: 1 + fetch-depth: ${{ github.event_name == 'push' && '0' || '1' }} persist-credentials: false - name: Set up .NET diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6e0f103b..51a95ea5 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..10be22d1 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 @@ -123,7 +125,26 @@ form is **not a valid workflow**, only tolerated until the ledger's review gate replaces it. `agent review` is judged by main's script on trusted triggers and posted by the gates App; a required check is spoofable by name unless pinned to an App. A human pushes to a `land/` branch and fast-forwards `main` -to its checked head, or merges a review-free pull request. Work not ready for +to its checked head, or merges a review-free pull request. + +**Several approved heads land as one train** (#277, ADR 0243 point 10), so +that every gate runs once for all of them: +- `eng/land.cs -- --train N1 N2 …` merges the heads onto `main`, in that + order, by `git merge-tree`. Each merge is a `Landing-only: merge` commit, and + a conflict refuses the train, because a merge is never resolved by hand. + The train's head is pushed to `land/train-N1-N2-…`. +- Each member's head must be approved for its exact sha and green on its own, + and its `Completes:` line is checked against its own commits. +- At most one member carries a release descriptor, and it goes last. The + branch is then `land/release-`, and the release is cut from the + train's head. +- `agent review` admits the train's head when every merge's second parent is + an approved pull request's head. +- `--main` fast-forwards `main` only to that verified train. If a gate is + red, the train lands nothing: `land.cs` prints it without its last member, + and each member can still land on its own. + +Work not ready for the trunk lives behind a feature flag or stays local, **not on a long-lived branch**. Human review gates a *release*, as the approval of its descriptor's pull request. @@ -159,10 +180,52 @@ 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). A train checks each member's line against its own commits; + - 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` (or `--train … --main`, for every member), + 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 @@ -204,6 +267,8 @@ dotnet run eng/ci.cs # the whole pipeline, as CI r dotnet run eng/ci.cs -- --list # and what it consists of, to run one dotnet run eng/land.cs -- # maintainer: push an approved head to land/ dotnet run eng/land.cs -- --main # then fast-forward main to it +dotnet run eng/land.cs -- --train … # maintainer: several approved heads as one train, in one run +dotnet run eng/land.cs -- --train … --main dotnet build Varve.slnx -c Release # zero warnings, or it is broken dotnet run eng/dependency-register.cs -- --base origin/main git submodule update --init --recursive # needed for conformance @@ -567,8 +632,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/0088-only-checked-commits-reach-main.md b/docs/adr/0088-only-checked-commits-reach-main.md index 5440a641..95eb1855 100644 --- a/docs/adr/0088-only-checked-commits-reach-main.md +++ b/docs/adr/0088-only-checked-commits-reach-main.md @@ -108,6 +108,29 @@ restriction to `main` and `v*` tags and has no reviewer. The ruling, The same ADR relies on point 2's `land/` route for an approved pull request's head, which ADR 0087's amendment of 2026-10-08 opens to agent commits. +### Amendment, 2026-11-11 — landing by fast-forward or merge commit, and trains + +Filed by the session for #230 with ADR +[0243](0243-chain-builds-ahead-of-main.md), unaccepted until the maintainer +accepts it (ADR 0066). It adds detail to point 2 and changes no ruling. + +- **`main` moves only by fast-forward**, to a checked head. A merge commit + made on a branch is a commit like any other: it is checked on the branch, + and `main` is fast-forwarded to it. The merge button is not a route to + `main`, and a squash merge is not either, for a `chain/*` branch: a chain + branch's stable tags must stay reachable from `main` after it lands + (0243 point 7, `ChainBranchesAreNeverRewritten`). +- **A landing train is one checked head** (0243 point 10, `LandingTrains`): + - `eng/land.cs` merges two or more approved pull request heads onto `main` + in a stated order; + - each merge is a `Landing-only: merge` commit that `git merge-tree` + reproduces; + - the head goes to `land/` like any other, and every gate runs once there. + + `agent review` admits the head when every merge's second parent is an + approved pull request's head. So point 3 holds: each member is approved on + its own head, and nothing on the `land/` branch adds an approval. + ## Alternatives considered - **Keep 0032**: checks after the push, fixed forward. Rejected: it cannot diff --git a/docs/adr/0102-a-release-is-a-descriptor.md b/docs/adr/0102-a-release-is-a-descriptor.md index 26929055..79c39871 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,64 @@ 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). + +## Amendment, 2026-11-11 — chain builds are not releases + +Filed by the session for #230 with ADR +[0243](0243-chain-builds-ahead-of-main.md), unaccepted until the maintainer +accepts it (ADR 0066). It adds detail to what a release is, and changes no +ruling. + +- **A chain build is outside a release's scope** (0243 point 4, + `ThreeBuildClasses`): + - it has no descriptor, no `CHANGELOG.md` entry and no `v*` tag; + - it publishes to a GitHub Release marked prerelease, never to nuget.org; + - it is not read forever. +- **The release workflow refuses `chain/*`**, a branch or a tag, and a commit + that only a `chain/*` ref reaches. The chain-build workflow refuses a commit + that is not a chain stable tag. Both checks are built in step 4 of #230. +- **A release can ride in a landing train**, as its last member, and at most + one per train (0243 point 10). The release is cut from the train's head, + which is the commit `main` is fast-forwarded to. The human review that gates + it is still the approval of the descriptor's pull request on its own head; + the train's head is that head merged onto `main` by a reproducible + `Landing-only: merge`. diff --git a/docs/adr/0241-model-names-refused-identifiers-allowed.md b/docs/adr/0241-model-names-refused-identifiers-allowed.md new file mode 100644 index 00000000..d9122750 --- /dev/null +++ b/docs/adr/0241-model-names-refused-identifiers-allowed.md @@ -0,0 +1,90 @@ +# 0241 — A commit message never names a model; a model's identifier is allowed + +## Status + +**Filed unaccepted by the session for #257, 2026-11-10** (ADR 0066). Decided +by the maintainer on 2026-10-27, in the scope of +[#169](https://github.com/Hafeok/Varve/issues/169). Acceptance is the +maintainer's act, or his delegate's for a decision he has read (ADR 0239). + +**Supersedes one ruling of [0033](0033-commit-traceability.md)**, +`ModelNamedInTheRecordNotTheCommit`, as its amendment of 2026-10-02 stated it. +The half about the record is restated here unchanged; the half about the +commit message is reversed. Under ADR 0068 point 2, that reversal is a change of +meaning, so it is a supersession, not an amendment. The ruling moves to this +ADR's set under the same key (ADR 0068 point 4). + +## Context + +ADR 0033's amendment of 2026-10-02 said a commit message never carries a +model's *identifier*, the string a provider's API takes to select a model, +and allowed a co-author trailer that names the product. Its reason was that a +commit message cannot be corrected without rewriting the trunk (ADR 0032), +and the record is where the fact can be corrected. + +In practice the trailer did the opposite. The tool these sessions use writes a +co-author trailer that names the model by its marketing name, family and +version (`Claude Opus 5.5`). `main` carried 241 such commits by 2026-10-27, +all signed and merged, so they stay. The name was in the commit, and the +identifier was barred. The identifier is what a reader can resolve exactly +and what the record holds; a name is the form that drifts and gets +advertised. + +On 2026-10-27 the maintainer ruled the other way round, for #169's gate: +**names refused, identifiers allowed.** `AGENTS.md` and `CONTRIBUTING.md` +still said the reverse, and a gate built to the ruling would contradict an +accepted decision. This ADR records the ruling so that the gate, the rule +files and the ledger agree. + +## Decision + +1. **A commit message never carries a model's name.** A name is a model + family word (`Opus`, `Sonnet`, `Haiku`, `Fable`, `Mythos`, and any family + the gate's configuration adds), alone or after `Claude`, followed by a + version: `Opus 5.5`, `Claude Sonnet 5.5`. The rule covers the subject, the + body and every trailer. +2. **A model's identifier is allowed**, in the API form a provider takes + (`claude---`), as is the bare product name in a co-author trailer: + `Co-Authored-By: Claude `. +3. **The traceability record still names the tool and the model exactly**, + unchanged from 0033's amendment: the model is the identifier the session's + metadata reports. Where the tool will not write it into a pushed file, the + record says so where the model belongs and the maintainer fills it in. A + blank model is an incomplete record. +4. **History is left as it is.** #169's gate checks the commits a pull + request or a `land/` branch adds over `main`, never `main`'s history. + +## Alternatives considered + +- **Amend 0033 instead.** Rejected: ADR 0068 point 2 makes a change of meaning + a supersession, and this reverses which form is allowed. +- **Refuse both names and identifiers.** Rejected by the maintainer's ruling. + An identifier in a commit is harmless and exact. Refusing it would also + refuse the one form a tool can write mechanically from its own metadata. +- **Keep 0033's rule and fix the trailer.** Not chosen. It leaves the rule + barring the precise form and allowing nothing that says which model wrote + the commit, while the gate that would enforce it does not exist. + +## Consequences + +- **`AGENTS.md` and `CONTRIBUTING.md` change with this ADR.** **Every + commit** and **Never** state the ruling, and so does `CONTRIBUTING.md`'s + traceability paragraph. +- **#169's gate implements this ADR**, not 0033's amendment. Its rule page + cites both: 0033 for the record, 0241 for the commit message. +- **The 241 commits on `main` that name a model stay.** Nothing rewrites the + trunk (ADR 0032). +- **Until this ADR is accepted**, `ModelNamedInTheRecordNotTheCommit` is filed + unaccepted in this ADR's set, and a citation of it is `CS0618` (ADR 0066). + +## Checks + +- **Checked against the accepted ADRs.** Touches: + - **0033:** one ruling superseded, as above. Every other ruling stands, + `TraceabilityRecordPerSession` and `ProseNamesNoProduct` included. + - **0032:** the trunk is not rewritten. + - **0066, 0068:** filed unaccepted; the ruling moves under its key. +- **Layer ownership.** None. +- **Analyzer rule.** None here. The gate is #169's, in the commit-message stage + beside `eng/issue-refs.cs` and `eng/dco.cs`. +- **Open questions owned.** None. diff --git a/docs/adr/0243-chain-builds-ahead-of-main.md b/docs/adr/0243-chain-builds-ahead-of-main.md new file mode 100644 index 00000000..d703b3b7 --- /dev/null +++ b/docs/adr/0243-chain-builds-ahead-of-main.md @@ -0,0 +1,389 @@ +# 0243 — Chain builds ahead of main, and landing trains + +## Status + +**Filed unaccepted by the session for #230, 2026-11-11** (ADR 0066). Decided +by the maintainer on [#230](https://github.com/Hafeok/Varve/issues/230), +accepted there as written on 2026-11-06; the review of 2026-11-11, posted on +the same issue, settled its four open questions and added seven rulings. +Every ruling the review settled or added is marked *settled in review, +2026-11-11*. Where the review and #230's text differ, the review is the later +ruling, and **Differences from #230** below lists each one. Acceptance is the +maintainer's act, or his delegate's for a decision he has read (ADR 0239). + +**Amends [0088](0088-only-checked-commits-reach-main.md)** (landing by +fast-forward or merge commit; landing trains) and +**[0102](0102-a-release-is-a-descriptor.md)** (chain builds are outside a +release's scope; the release workflow refuses `chain/*`), each by a dated +amendment in its own file. Supersedes nothing. Records decision set +[`chain-builds-ahead-of-main`](../decisions/chain-builds-ahead-of-main.md). + +**Built in steps**, one pull request and one issue each, in this order: + +| Step | Issue | What | +|---:|---|---| +| 0 | #230 | this ADR | +| 1 | #277 | landing trains | +| 2 | — | the chain graph and its gate | +| 3 | — | the unstable-build marker | +| 4 | — | stable tags and chain builds | +| 5 | — | interference by trial merge | +| 6 | — | allocators designed out | + +Steps 1 and 2 are independent of each other. Step 3 lands before any chain +build is published. Each step cites this ADR's keys. + +## Context + +**Landing is serial.** Each landing takes one full CI run, about 70 minutes, +and moves `main`, so every other open pull request falls behind and needs a +merge from `main` and another run before it can land. On 2026-11-10 twelve +pull requests waited in that line. ADR 0239's delegate made the landing faster +to carry out, not shorter. + +**Work cannot build on unlanded work, and nobody can try a feature before it +lands.** A session starts only after the one before it lands, so independent +work queues behind unrelated work, and dependent work cannot be consumed until +it is on `main`. The sessions of #257 (#259, #260, #261) already stack branch +on branch by hand, with nothing to say what a branch rests on or whether that +is safe. + +#230 addresses the second problem. It does not address the first: chain +branches still land one at a time. The review added landing trains for that. + +## Decision + +### 1. A feature tree rooted at `main` + +- **Unlanded features are `chain/` branches.** Each one declares + exactly one parent: `main` at a release, or another chain branch at one of + its stable tags. +- Branches may diverge but never join: **no diamonds**. Independent features + are siblings, so only a feature's descendants carry its risk. +- **Chain branches are opt-in** *(settled in review, 2026-11-11)*. + Independent work stays on its session branch with a pull request to `main`. + Nothing in flight is migrated. + +### 2. Accepted decisions gate stacking + +- **A feature may have children only once its whole decision set is + accepted** at the stable tag the child forks from. Descendants then rest on + settled design, and rejecting an ancestor at review stays cheap. +- **Stable implies the branch's own decisions are accepted** *(settled in + review, 2026-11-11)*. An unaccepted decision fails the build (`CS0618`, ADR + 0066), so a branch with one is never green and never gets a stable tag. The + gate still checks explicitly that the parent's decision set is accepted at + the forked tag, because the parent's decisions are the ones the child relies + on. +- **Depth has a backstop**, 3 for Varve, changeable only by dated amendment. +- Once accepted, a parent's decisions change only by supersession, which its + children receive by merging forward. + +### 3. Stable points, and who tags them + +- **A point is stable when every gate is green on its head.** A stable point + gets an immutable tag. Only a stable point can be forked from or built into + packages. +- **A stable tag is cut by a workflow, on request** *(settled in review, + 2026-11-11; open question 2)*. The request is a `workflow_dispatch` that + names a head. The workflow verifies that every gate is green on that exact + sha, then tags. It is never cut by hand and never on every green head: a + human adds nothing to "every gate is green", and tagging every green head + makes tags nobody asked for. +- Who may request one is today's rule, ADR 0239: the maintainer or his + delegate. #246 (agent grants) is where that would change. + +### 4. Three build classes + +| Class | Built from | Feed | Format read-forever | +| --- | --- | --- | --- | +| Release | `main`, through the release descriptor (ADR 0102) | nuget.org | yes | +| Chain build | a `chain/*` stable tag | a GitHub Release marked prerelease | no | +| CI artifact | any commit | Actions artifacts | no | + +- **Chain builds never go to nuget.org.** +- **A chain build publishes `.nupkg` files and their SBOMs only** *(settled + in review, 2026-11-11; open question 3)*. It publishes no GHCR image and no + WASM bundle under a chain version. Either can be added by dated amendment + when someone needs one. + +### 5. The unstable-build marker is a reserved header byte + +*(Settled in review, 2026-11-11; open question 1.)* + +- **A store written by a chain build carries a non-zero reserved header + byte**, which a chain build sets in every file header it writes. A released + reader already refuses a non-zero reserved byte + (`docs/spec/storage-format.md` §1: "written zero and checked zero"), so + every release ever cut refuses the store, with no new refusal code. +- **Not a marker file.** A backup is a copy of the log, and a marker file + beside `log/` is lost in exactly that copy. A header byte travels with the + data. +- **A chain build writes only into a store that already carries the byte, or + one it creates.** It never writes into a store a release wrote, because it + cannot mark headers that already exist. +- It is a format change, so it goes through the format inventory (ADR 0216): + an inventory entry, `docs/spec/storage-format.md`, and fixtures. + +### 6. Path-encoded versions + +- **A chain build's version** is `.chain..[..]…` + (SemVer 2.0 §11). For example, `0.1.0-preview.17.chain.inference.2.explorer.1`. +- **On a stable base**, the suffix attaches to the next patch: + `1.0.1-chain.inference.1`. +- **Ordering.** A descendant extends its parent's identifiers, so it sorts + above its parent. A chain build sorts above its base preview and below the + next one: everything on preview.17 sorts below preview.18. Step 4 tests this + against SemVer 2.0 §11 and against NuGet's version comparer. +- **Feature names** are restricted to `[0-9a-z-]`, with no leading zero in an + identifier that is all digits. The gate refuses anything else. +- The git tag is `chain/`, and the build reads its version from the + tag. + +### 7. Chain branches are never rewritten + +- **No rebase, squash or force-push on a chain branch, and tags never move.** +- **Landing.** A child never lands before its parent; siblings land in any + order. +- **When `main` moves**, open chain branches merge `main` forward; they never + rebase. +- **Re-parenting** is allowed only onto a descendant of the current parent. +- **Abandonment** is a `closed/` tag; no revert is needed. +- **A chain branch never lands by squash** *(settled in review, 2026-11-11)*. + A stable tag must stay reachable from `main` after landing, and a child is + forked from its parent's commits. So a chain branch lands by fast-forward or + by a merge commit. **This constrains the planned gh-tt delivery** (#243's + probe): if `gh tt deliver` replaces `land/`, it must not squash a chain + branch. That ADR inherits this constraint. + +### 8. Dependency and interference + +- **Dependency is declared at planning** (`parent`, `touches`) and made + unavoidable by git. Undeclared use fails the build, and the fix is to + re-parent. +- **Interference between non-ancestor pairs is found by bounded trial + merges** *(settled in review, 2026-11-11)*: + - every non-ancestor pair gets a `git merge-tree` check, which costs + seconds; + - a build and test in a scratch tree runs only for a pair whose declared + `touches` overlap, or whose merge-tree result changes the same project; + - trial builds run one at a time and never block a branch's own CI. The + runner queue is already the constraint; + - nothing from a trial is pushed. +- **Recorded interference blocks landing of the later of the two** until one + of them merges the other's parent forward or re-parents. It is also the + evidence that a landing train of siblings rests on (point 10). + +### 9. The chain graph, checked with Varve + +- **The tree is modelled as RDF.** + - `.chain/feature.ttl` on each chain branch holds the declared facts: its + one parent and its `touches`. + - Facts derived from git are added on every run. + - The graph is derived on every run and is never a source of truth. +- **The rules are a SHACL shapes graph**, validated by `Varve.Shacl`. Depth, + diamonds and acceptance use SPARQL-based constraints (`Varve.Shacl.Sparql`). + Each shape gets a violating and a conforming fixture graph, as an analyzer + rule does. +- **The Varve the gate runs on is pinned** *(settled in review, 2026-11-11)*. + - It is a released version, pinned in a lockfile and bumped like any + dependency. + - "The latest release" floats, and a floating gate cannot be reproduced. + ADR 0224's locked restore applies to it like everything else. + - It never runs on the code under test. A broken release can block chain + work, but never a release. +- **The vocabulary namespace is `https://w3id.org/varve/chain#`** *(settled + in review, 2026-11-11; open question 4)*. When the gate moves to + how-we-work it takes a namespace of its own, and the move is a rename + recorded then. The maintainer may overrule this on #230 before step 2. +- **The gate is generic.** It lives under `tools/`, beside repo-standard, and + is written so that it can move to how-we-work with the other process gates + (ADR 0086). Its code has no Varve-specific path or name; those are only in + its configuration. Varve is its first consumer. +- **What the gate checks:** + - one declared parent; + - the fork point is a stable tag of the parent; + - `main` is an ancestor; + - no diamond; + - the parent's decision set is accepted at the forked tag; + - depth is within the backstop; + - existing chain tags are reachable; + - no rebase or force-push: the previous head is an ancestor of the new one; + - the version's base, path and build number are correct; + - the chain-build workflow refuses a commit that is not a chain stable tag, + and the release workflow refuses a chain commit. + +### 10. Landing trains + +*(Settled in review, 2026-11-11. Not in #230 as written.)* + +- **A train is one head that merges two or more pull request heads**, in a + stated order, onto the current `main`. +- **Members.** Each member's own head is approved for its exact sha, and was + green on its own. +- **Merge commits.** The train's merge commits carry `Landing-only: merge` + and are reproducible by `git merge-tree`. A merge that needed a resolution + by hand disqualifies the train. +- **The merge is pinned** *(settled in review, 2026-11-11)*. `eng/land.cs`, + which builds a train, and `eng/agent-review.cs`, which judges it, run + `git merge-tree` under the same settings, stated once in each: + - attributes are read from the train so far (the merge's first parent), + never from a working tree; the global and system attribute files are off; + - rename detection is on, directory renames are off, and the rename limit + is fixed; + - a repository-local `info/attributes` refuses the train, because it + changes how a merge resolves and neither tool can switch it off. + + A tree that differs anyway, for instance from a different git, **refuses + the train**. The lander builds it again, and nothing is resolved by hand. + A merge driver that step 6 adds (`merge=union`) comes from the tree, so it + is pinned the same way. +- **One run.** Every gate runs once, on the train's head. Green + fast-forwards `main` to it. +- **Releases.** A train carries at most one release descriptor, and that + member is last. The release is cut from the train's head, as today. +- **Failure.** A red train lands nothing. `land.cs` prints the train without + its last member, so the lander can retry a shorter one. Members stay + individually landable. +- **Closings.** #209's check runs per member. Every member's `Completes:` + issues need their trailers, and after the fast-forward every one of them is + checked closed. +- **When both members are chain branches**, the train consults the trial + merge record of point 8. +- **Who may land a train** is whoever may land today: the maintainer, or his + delegate (ADR 0239). #246 is where that would change. + +### 11. Shared allocators are designed out + +- **ADR numbers are assigned at landing** *(a step of its own, settled in + review, 2026-11-11)*. + - On a branch, an ADR file is named by its decision set's key and carries + no number, and text cites keys. + - `land.cs` assigns the next free number at landing, renames the file and + rewrites the references, in a commit marked `Landing-only: renumber` that + it can reproduce. + - **Until step 6 lands**, a session takes the next free number and checks + every open pull request for a collision, as today. +- **The spec version and the changelog cut** belong to the release + descriptor. +- **`*.Unshipped.txt` merge as `merge=union`**, through `.gitattributes`. + +## Differences from #230 + +The review of 2026-11-11 is the later ruling. Where it changes #230's text: + +| #230 as written | This ADR | +|---|---| +| "The gate runs on the latest Varve release from nuget.org" | It runs on a **pinned** release, in a lockfile, bumped like any dependency (point 9) | +| "Interference … detected by trial merges on every stable tag (build + test in a scratch tree)" | **Bounded**: `git merge-tree` for every pair; build and test only for overlapping `touches` or the same project; one at a time; never blocking (point 8) | +| Open question 1, marker byte or marker file | **A reserved header byte**, and a chain build writes only into a store it marked (point 5) | +| Open question 2, who cuts a stable tag | **A workflow, on request**, after checking every gate on that exact sha (point 3) | +| Open question 3, images and the WASM bundle | **`.nupkg` files and SBOMs only** (point 4) | +| Open question 4, the namespace | **`https://w3id.org/varve/chain#`** until the gate moves (point 9) | +| "No rebase, squash or force-push on chain branches" | Adds that a chain branch **never lands by squash**, and records it as a constraint on gh-tt's delivery (#243) (point 7) | +| The acceptance rule for stacking | Adds that **stable implies the branch's own decisions are accepted** (point 2) | +| "ADR numbers assigned at landing" | Its own **step 6**; until then, today's rule (point 11) | +| (absent) | **Landing trains** (point 10) | +| (absent) | **Chain branches are opt-in**; nothing in flight is migrated (point 1) | + +## Alternatives considered + +- **Status quo, one session at a time.** Rejected: independent work queues + behind unrelated work. +- **A single linear chain.** Rejected: unrelated features would share each + other's risk. +- **Chain builds on nuget.org as `beta`.** Rejected: + - floating consumers would pick them up silently; + - nuget.org has no deletion; + - it triggers the format freeze; + - `beta` sorts below `preview`. +- **A GitHub Packages feed.** Rejected: it needs authentication even for + public packages, so local use needs a PAT. +- **Position numbers or session ids in versions.** Rejected: positions only + order one line, and session ids are not readable. +- **A fixed depth of 3 as the rule.** Rejected as the rule, because it is + arbitrary: the real cost is review pressure on unaccepted ancestors, which + the acceptance rule addresses. 3 stays as the backstop. +- **Chain state in a script.** Rejected: it loses the declarative rules, the + queryable history and the dogfooding. +- **A marker file beside `log/`** (open question 1). Rejected: a backup + copies the log and loses the file. +- **Stable tags cut by hand, or on every green head** (open question 2). + Rejected: by hand adds nothing to "every gate is green", and every green + head makes tags nobody asked for. +- **The gate on the latest release.** Rejected: a floating gate cannot be + reproduced. +- **A build and test for every non-ancestor pair.** Rejected: the runner + queue is already the constraint. `git merge-tree` finds textual + interference for every pair at no cost, and a build runs only where the + declared or merged footprint overlaps. +- **GitHub's merge queue, in place of trains.** Not chosen. A merge queue + lands through GitHub's merge, on branches it names itself. This repository + lands a checked head by fast-forward through `land/` (ADR 0088 point 2), + cuts a release only from a head landed that way (ADR 0102), and judges + agent commits on that push (ADR 0087). A train keeps `land.cs` and the + `land/` route, and adds only a head built from approved heads. + +## Consequences + +- **Landing costs one run per train, not per pull request.** A train that + goes red costs one more run per shorter retry, and its members stay + landable one at a time. +- **`agent review` judges a train.** On a `land/` push whose head is not an + approved pull request's head, it admits a head whose first-parent walk to + `main` is only `Landing-only: merge` commits, each one reproducible by + `git merge-tree` and each one's second parent an approved pull request's + head. The approvals are still the members' own; nothing on the `land/` + branch can add one (step 1). +- **Independent features stop waiting for each other**, and any stable + feature can be consumed from the repository. +- **Squash merges are gone for chain branches**, and forward merges add + merge commits. +- **ADR numbers on chain branches are provisional** until step 6; text cites + keys. +- **The gate depends on a pinned Varve release.** A broken release can block + chain work, but never a release. +- **Every artefact these steps change:** + - dated notes on ADR 0088 and ADR 0102 (this pull request); + - `AGENTS.md`'s landing section and `docs/releases.md` (step 1); + - the format inventory and `docs/spec/storage-format.md` (step 3); + - `.github/workflows/release.yml`, and a tagging workflow and a chain-build + workflow (step 4); + - the contributing guide, the session prompt template, `.gitattributes`, + and the issue template's `parent` and `touches` (step 6). + +## Not in this work + +- Migrating open pull requests to chain branches. +- Replacing `land/` with gh-tt. That is its own ADR, and it inherits the + no-squash constraint (point 7). +- Agent grants (#246). Where a step needs "who may tag" or "who may land a + train", it uses today's rule (ADR 0239) and says where #246 would change it. + +## Checks + +- **Checked against the accepted ADRs** in `docs/adr/`: + - **0088**: `main` moves only to a checked commit. A train's head is + checked like any other, and a merge commit made on a branch is a commit + like any other. Amended to say both; + - **0102**: a release is cut from a commit landed through `land/`, and a + train's head is one. Amended to put chain builds outside a release's + scope; + - **0072** and **0216**: a reserved byte is checked zero, and a format + change goes through the inventory (step 3); + - **0087**: an approval is of a pull request's head, and a train's members + are each approved; + - **0224**: the gate's Varve is restored locked; + - **0239**: who may tag and land today. + + 0085 is superseded by 0102. No conflict. +- **Supersedes** no ADR. +- **The brief.** It does not depart from `docs/brief.md`: the brief governs + the product, and this ADR governs how changes reach it. +- **Dependencies.** None new. The gate (step 2) consumes Varve's own released + packages, pinned under ADR 0009's register like any dependency. +- **Revisit condition.** This ADR is accepted ahead of the evidence that + trains land faster than serial landing. Revisit it if, over the first ten + trains, more than half go red and land shorter, or the median train runs + longer than two serial landings. +- **AOT, trimming or WASM.** Not affected. diff --git a/docs/adr/README.md b/docs/adr/README.md index 8f5b8deb..6945355d 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -79,8 +79,8 @@ superseding ADR rather than an edit. | [0085](0085-a-release-per-milestone.md) | A release per milestone, and the next milestone waits for it | **Superseded by 0102**; complemented 0029 and 0035 | | [0086](0086-home-of-the-eng-gates.md) | Home of the eng/ gates | Accepted; complements 0036 and 0039 | | [0087](0087-the-identity-map.md) | The identity map: who signs off, accepts and approves for an agent | Accepted; enforces 0034 point 3; amended 2026-10-06 (approval by comment on one's own pull request; the exit to a signed ledger Review); amended again 2026-10-06 (the judging script is always main's; only App-pinned checks count; approval by comment is not a valid workflow); amended 2026-10-08 (an approved head lands through land/) | -| [0088](0088-only-checked-commits-reach-main.md) | Only checked commits reach main | Accepted; **supersedes 0032**; amended 2026-10-06 (required check names are checked); amended 2026-10-08 (a release's review is its descriptor's approval; `HumanReviewGatesReleases` superseded by 0102) | -| [0102](0102-a-release-is-a-descriptor.md) | A release is a descriptor, and landing it cuts it | **Accepted** (filed unaccepted, ADR 0066); **supersedes 0085** and 0088's `HumanReviewGatesReleases`; amends 0087 rule 3 | +| [0088](0088-only-checked-commits-reach-main.md) | Only checked commits reach main | Accepted; **supersedes 0032**; amended 2026-10-06 (required check names are checked); amended 2026-10-08 (a release's review is its descriptor's approval; `HumanReviewGatesReleases` superseded by 0102); amended 2026-11-11 (landing by fast-forward or merge commit, and trains; filed unaccepted, #230) | +| [0102](0102-a-release-is-a-descriptor.md) | A release is a descriptor, and landing it cuts it | **Accepted** (filed unaccepted, ADR 0066); **supersedes 0085** and 0088's `HumanReviewGatesReleases`; amends 0087 rule 3; amended 2026-11-11 (chain builds are not releases; filed unaccepted, #230) | | [0036](0036-containerised-development.md) | Containerised development and the local pipeline | Accepted | @@ -454,6 +454,22 @@ Filed by the landing session for [#244](https://github.com/Hafeok/Varve/issues/2 |---:|---|---| | [0239](0239-the-maintainers-delegate.md) | The maintainer's delegate: one session on his machine may approve, land and accept for him | Filed unaccepted (#244) | +## Model names in commit messages (#257) + +Filed by the session for [#257](https://github.com/Hafeok/Varve/issues/257), recording the maintainer's ruling of 2026-10-27 for [#169](https://github.com/Hafeok/Varve/issues/169), unaccepted until he accepts it (ADR 0066). Numbered 0241: 0240 is taken on another branch. It supersedes one ruling of 0033. + +| # | Title | Status | +|---:|---|---| +| [0241](0241-model-names-refused-identifiers-allowed.md) | A commit message never names a model; a model's identifier is allowed; supersedes one ruling of 0033 | Filed unaccepted (#257) | + +## Chain builds and landing trains (#230) + +Filed by the session for [#230](https://github.com/Hafeok/Varve/issues/230), recording the maintainer's decision of 2026-11-06 and his review of 2026-11-11, unaccepted until he accepts it (ADR 0066). Numbered 0243: 0240–0242 are taken by open pull requests (#268; #259 and #264, #278; #267). It amends 0088 and 0102 by dated amendments. + +| # | Title | Status | +|---:|---|---| +| [0243](0243-chain-builds-ahead-of-main.md) | Chain builds ahead of main, and landing trains | Filed unaccepted (#230) | + ## Conformance — differential testing against Oxigraph | # | Title | Status | diff --git a/docs/decisions/chain-builds-ahead-of-main.md b/docs/decisions/chain-builds-ahead-of-main.md new file mode 100644 index 00000000..d77d1ab9 --- /dev/null +++ b/docs/decisions/chain-builds-ahead-of-main.md @@ -0,0 +1,38 @@ +--- +set: chain-builds-ahead-of-main +namespace: varve +adr: 0243 +decisions: + - key: FeatureTreeRootedAtMain + statement: "Unlanded features are chain/ branches, each declaring exactly one parent, main at a release or a chain branch at one of its stable tags, and branches never join" + - key: ChainBranchesAreOptIn + statement: "Chain branches are opt-in; independent work stays on its session branch with a pull request to main, and nothing in flight is migrated" + - key: AcceptedDecisionsGateStacking + statement: "A feature may have children only once its whole decision set is accepted at the stable tag they fork from, a stable point implies its own decisions are accepted, and depth is within a backstop of 3 changed only by dated amendment" + - key: StableTagsCutByWorkflowOnRequest + statement: "A point is stable when every gate is green on its head, and its immutable tag is cut by a workflow on a request naming that head, after verifying every gate on that exact sha, never by hand and never on every green head" + - key: ThreeBuildClasses + statement: "A release is built from main through its descriptor to nuget.org and is read forever; a chain build is built from a chain stable tag to a GitHub Release marked prerelease and is not; a CI artifact is built from any commit; chain builds never go to nuget.org" + - key: ChainBuildsPublishPackagesAndSbomsOnly + statement: "A chain build publishes .nupkg files and their SBOMs only, with no container image and no WASM bundle under a chain version until a dated amendment adds one" + - key: UnstableBuildMarkerIsAReservedHeaderByte + statement: "A chain build sets a reserved header byte in every file header it writes, which every release refuses, and writes only into a store that already carries it or that it creates; it is a format change under the format inventory" + - key: PathEncodedChainVersions + statement: "A chain build's version is .chain..[..]..., read from the tag chain/, sorting above its parent and base preview and below the next preview, with feature names in [0-9a-z-]" + - key: ChainBranchesAreNeverRewritten + statement: "A chain branch is never rebased, squashed or force-pushed, its tags never move, it merges main forward, a child never lands before its parent, and it lands by fast-forward or merge commit, never by squash" + - key: InterferenceByBoundedTrialMerge + statement: "Every non-ancestor pair of chain branches gets a git merge-tree check, a build and test runs one at a time only where declared touches overlap or the merge changes the same project, and recorded interference blocks landing of the later of the two" + - key: TheChainGraphIsCheckedByShacl + statement: "The chain graph is derived on every run from .chain/feature.ttl and git, never a source of truth, and validated by a SHACL shapes graph on a pinned released Varve, in the namespace https://w3id.org/varve/chain# until the gate moves to how-we-work" + - key: LandingTrains + statement: "Two or more approved, individually green pull request heads may land as one train: reproducible Landing-only merge commits onto main in a stated order, gated once on the train's head, at most one release descriptor as the last member, and a red train landing nothing" + - key: AllocatorsDesignedOut + statement: "ADR numbers are assigned at landing by land.cs in a reproducible Landing-only renumber commit, the spec version and changelog cut belong to the release descriptor, and *.Unshipped.txt merge by union" +--- + +The rulings of [ADR 0243](../adr/0243-chain-builds-ahead-of-main.md), +filed unaccepted by the session for #230 (ADR 0066). The maintainer decided +them on #230 (2026-11-06) and in its review (2026-11-11); acceptance is his +act on the pull request, or his delegate's for a decision he has read +(ADR 0239). diff --git a/docs/decisions/commit-traceability.md b/docs/decisions/commit-traceability.md index ea411ebb..f11898a4 100644 --- a/docs/decisions/commit-traceability.md +++ b/docs/decisions/commit-traceability.md @@ -35,10 +35,6 @@ decisions: statement: "Prose describes the project as developed with AI assistance under human review and names no product, while records and issues name the tool and model" accepted-by: mailto:emil@okkels-klein.dk accepted-at: 2026-09-22T00:00:00Z - - key: ModelNamedInTheRecordNotTheCommit - statement: "The traceability record names the model by the identifier the session's metadata reports, filled in by the maintainer where the tool will not write it, and a commit message never carries a model's API identifier" - accepted-by: mailto:emil@okkels-klein.dk - accepted-at: 2026-10-02T00:00:00Z - key: AgentsMdHoldsTheRules statement: "The agent rules live in the vendor-neutral AGENTS.md, and CLAUDE.md is one line pointing at it" accepted-by: mailto:emil@okkels-klein.dk @@ -48,6 +44,5 @@ decisions: The rulings of [ADR 0033](../adr/0033-commit-traceability.md) still in force, one line each. The ADR is the narrative; this is what code cites. Acceptance is transcribed from the ADR's Status (ADR 0062). -`ModelNamedInTheRecordNotTheCommit` is the ruling of the amendment of 2026-10-02. It is filed unaccepted, -and its `accepted-at` will be the date the maintainer accepts it, which can be no earlier than the -amendment (ADR 0068). +Moved to a later set: `ModelNamedInTheRecordNotTheCommit`, the ruling of the amendment of 2026-10-02, is +superseded by ADR 0241, which refuses a model's name in a commit message and allows its identifier, in its set. diff --git a/docs/decisions/model-names-refused-identifiers-allowed.md b/docs/decisions/model-names-refused-identifiers-allowed.md new file mode 100644 index 00000000..fd27d7d6 --- /dev/null +++ b/docs/decisions/model-names-refused-identifiers-allowed.md @@ -0,0 +1,13 @@ +--- +set: model-names-refused-identifiers-allowed +namespace: varve +adr: 0241 +decisions: + - key: ModelNamedInTheRecordNotTheCommit + statement: "The traceability record names the model by the identifier the session's metadata reports, filled in by the maintainer where the tool will not write it, and a commit message never carries a model's name, while a model's API identifier and the bare product name are allowed" +--- + +The ruling of [ADR 0241](../adr/0241-model-names-refused-identifiers-allowed.md), +filed unaccepted by the session for #257 (ADR 0066). It supersedes the ruling of +ADR 0033's amendment of 2026-10-02 under the same key, which moved here from +`commit-traceability` (ADR 0068 point 4). diff --git a/docs/releases.md b/docs/releases.md index f5379da2..ef85af33 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; @@ -170,6 +190,24 @@ one. is `git push origin <approved head>:land/release-<version>`, then `git push origin <approved head>:main`, with the checks that make either fail later. + + **A release can ride in a landing train** (#277, ADR 0243 point 10), as + its **last** member, and a train carries at most one: + + ```bash + dotnet run eng/land.cs -- --train <pr> … <release pr> # pushes the train to land/release-<version> + dotnet run eng/land.cs -- --train <pr> … <release pr> --main # once its checks are green + ``` + + The train's head merges each approved head onto `main` by `git + merge-tree`, in `Landing-only: merge` commits, and every gate and the + release's dry run run once there. `agent review` posts its check on that + head when every merge's second parent is an approved pull request's head. + So the head guard (below) holds for it, and the release is cut from the + train's head, the commit `main` is fast-forwarded to. The review that + gates the release is still the approval of the descriptor's pull request + on its own head. A train that goes red lands nothing; land it shorter, or + the release alone. 4. **Cut.** The push to `main` starts `.github/workflows/release.yml`: - **plan** validates and resolves the descriptor and applies the head guard; @@ -209,7 +247,8 @@ one. **The head guard.** A release is cut only at a commit that carries a successful `agent review` check run from the gates App, which that App posts -only on an approved pull request's head or a `land/` head. **A descriptor +only on an approved pull request's head or a `land/` head: an approved head, +or a train of approved heads. **A descriptor landed by the merge button is not cut and leaves no tag**: the merge commit is not the head anyone approved. It is recovered by a **retro-cut**: a new pull request that adds `commit: <approved head>` to the same descriptor, which diff --git a/docs/sessions/2026-11-chain-builds.md b/docs/sessions/2026-11-chain-builds.md new file mode 100644 index 00000000..8f94e44d --- /dev/null +++ b/docs/sessions/2026-11-chain-builds.md @@ -0,0 +1,47 @@ +# Session, 2026-11-11: chain builds, steps 0 and 1 + +Developed with AI assistance under human review. The full records, with the +prompt, are one per issue: +- [`2026-11-11-issue-230-chain-builds-ahead-of-main.md`](../traceability/2026-11-11-issue-230-chain-builds-ahead-of-main.md); +- [`2026-11-11-issue-277-landing-trains.md`](../traceability/2026-11-11-issue-277-landing-trains.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, +tagged and accepted nothing. + +## Pull requests + +| PR | Step | Closes | +|---|---|---| +| [#280](https://github.com/Hafeok/Varve/pull/280), stacked on #261 | 0: ADR 0243, chain builds ahead of main, and landing trains; notes on 0088 and 0102 | #230 | +| stacked on #280 | 1: `land.cs --train`; `agent review` admits a train | #277 | + +They land after #259, #260 and #261, in that order. Once `agent review`'s +change is on `main`, they could themselves land as a train. + +## ADRs + +- **0243**, filed unaccepted: thirteen keys. It is #230's text with the review + of 2026-11-11 folded in, each settled point marked, and a table of every + difference from #230. +- **0088** and **0102**, amended 2026-11-11, add-only and unaccepted. + +## Issues + +- Opened: #277 (step 1); #278 (found: ADR 0241 is taken by both #259 and + #264). +- Closed by these pull requests: #230, #277. +- Steps 2 to 6 get their issues when each starts. + +## Test counts + +`land-fixtures` went from 12 cases to 22. `agent-review-tamper` gained 4 +cases. No test project changed. + +## Questions for the maintainer + +1. Should #230 close when 0243 lands with its rulings still unaccepted, or + should acceptance come first? +2. Do you want to overrule the `chain#` namespace before step 2? +3. Keep or strike the two things this session added to 0243: GitHub's merge + queue as a rejected alternative, and the revisit condition on trains. 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/docs/traceability/2026-11-11-issue-230-chain-builds-ahead-of-main.md b/docs/traceability/2026-11-11-issue-230-chain-builds-ahead-of-main.md new file mode 100644 index 00000000..185c938c --- /dev/null +++ b/docs/traceability/2026-11-11-issue-230-chain-builds-ahead-of-main.md @@ -0,0 +1,166 @@ +# Chain builds ahead of main: the ADR (step 0) + +> **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** | [#230](https://github.com/Hafeok/Varve/issues/230); [#277](https://github.com/Hafeok/Varve/issues/277) (step 1) and [#278](https://github.com/Hafeok/Varve/issues/278) (found: ADR 0241 taken twice) opened | +| **Date** | 2026-11-11 | +| **Tool** | Claude Code 2.1.322, a cloud session. Not the maintainer's delegate (ADR 0239 point 1) | +| **Model** | *Left for the maintainer.* 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_01NbWSYNzDrbT3ENstGnmH3N` | +| **Branch** | `claude/nice-mendel-495j4y`, on #261's head at a3578a2 | +| **ADRs** | [0243](../adr/0243-chain-builds-ahead-of-main.md) filed unaccepted; [0088](../adr/0088-only-checked-commits-reach-main.md) and [0102](../adr/0102-a-release-is-a-descriptor.md) amended, unaccepted | + +## The prompt + +The message: + +> Read AGENTS.md, then the session prompt in the attached varve-claude-code-chain-builds.md. The decision and my review are on #230. Start with step 0 (the ADR) and step 1 (landing trains), and post your plan on each issue before building. + +The session prompt, attached: + +> # Session prompt: chain builds ahead of `main` (#230) +> +> Paste into a fresh session on `Hafeok/varve`. Read `AGENTS.md` first and follow it, including the issue-log practice: comment on each issue when you start it, post your plan there before building, and file found work as issues when you find it. +> +> The decision is #230, accepted by the maintainer on 2026-11-06 as written. This prompt adds the rulings from the review of 2026-11-11 (also posted on #230) and an order of work. Where this prompt and #230 differ, this prompt is the later ruling; record each difference in the ADR. +> +> You are a cloud session, so you are not the maintainer's delegate (ADR 0239). File every decision unaccepted, post your heads, and ask. You can push branches of any name; you cannot delete branches or push tags, so anything that tags or deletes is a workflow. +> +> ## What this is for +> +> Two different problems, and the steps are ordered by which one hurts now: +> +> 1. **Landing is serial.** Each landing takes one full CI run (about 70 minutes), and moves `main`, so every other open pull request falls behind and needs a merge and another run. On 2026-11-10 twelve pull requests waited in line. #230 as written does not fix this; step 1 does. +> 2. **Work can't build on unlanded work, and nobody can try a feature before it lands.** That is #230 proper: the feature tree, stable tags, and chain builds. +> +> ## Rulings from the review +> +> **The open questions in #230, settled:** +> 1. **Unstable-build marker: a reserved header byte, not a marker file.** A backup is a copy of the log, and a marker file beside `log/` is lost in exactly that copy. A header byte travels with the data, and released readers already refuse a non-zero reserved byte (`docs/spec/storage-format.md` §1), so every release ever cut refuses it too. +> - A chain build sets the byte in every file header it writes. +> - A chain build opens for writing only a store that already carries the byte, or creates a new one. It never writes into a release-written store, because it can't mark headers that already exist. +> - It is a format change, so it goes through the format inventory (ADR 0216). +> 2. **A stable tag is cut by a workflow, on request, never by hand and never on every green head.** The request names a head (a `workflow_dispatch`); the workflow verifies every gate is green on that exact sha, then tags. A human adds nothing to "every gate is green", and tagging every green head produces tags nobody asked for. +> 3. **Chain builds publish `.nupkg` files and their SBOMs only.** No GHCR image and no WASM bundle under a chain version in this ADR. Add them by dated amendment when someone needs one. +> 4. **Vocabulary namespace:** `https://w3id.org/varve/chain#` for now. When the gate moves to how-we-work it takes a namespace of its own, and the move is a rename recorded then. (The maintainer may overrule this on #230 before step 2.) +> +> **Added by the review:** +> - **Landing trains (step 1).** Not in #230 as written. It is what removes the serial cost. +> - **The Varve version the gate runs on is pinned,** in a lockfile, and bumped like any dependency. "The latest release" floats, and a floating gate can't be reproduced (ADR 0224's locked restore applies to it like everything else). +> - **Trial merges are bounded.** Every non-ancestor pair gets a `git merge-tree` check, which costs seconds. A build and test in a scratch tree runs only for pairs whose declared `touches` overlap, or whose merge-tree result changes the same project. One at a time, and never blocking a branch's own CI. The runner queue is already the constraint. +> - **Stable implies the branch's own decisions are accepted.** An unaccepted decision fails the build (`CS0618`), so a branch with one is never green. Say so in the ADR; the explicit check "the parent's decision set is accepted at the forked tag" stays. +> - **Chain branches never land by squash.** A stable tag must stay reachable from `main` after landing, and a child is forked from its parent's commits. So the landing path for a chain branch is a fast-forward or a merge commit. This constrains the planned gh-tt delivery (#243's probe): if `gh tt deliver` replaces `land/`, it must not squash a chain branch. Record it as a constraint on that future ADR. +> - **ADR numbers assigned at landing** is a piece of work of its own (step 6). Until it lands, a session takes the next free number and checks every open pull request for a collision, as today. +> - **Chain branches are opt-in.** Independent work stays on its session branch with a pull request to `main`. Nothing in flight is migrated. +> +> ## Steps +> +> Each step is one pull request under its own issue, in this order. Steps 1 and 2 are independent of each other. Post the plan for a step on its issue before building it. +> +> ### Step 0 — the ADR +> +> File the ADR from #230's text, with the rulings above folded in and each one marked as settled in review on 2026-11-11. Unaccepted. Dated notes on ADR 0088 (landing by fast-forward or merge commit; trains) and ADR 0102 (chain builds are out of a release's scope; the release workflow refuses `chain/*`). `Closes #230` when it lands. Steps 1 to 6 cite its rulings. +> +> ### Step 1 — landing trains +> +> Open an issue: "Landing trains: several approved heads land in one run". +> +> - **A train** is one head that merges two or more pull request heads, in a stated order, onto the current `main`. +> - **Members:** each member's own head is approved for its exact sha and was green on its own. The train's merge commits carry `Landing-only: merge` and are reproducible by `git merge-tree`; a merge that needed a hand resolution disqualifies the train. +> - **One run:** every gate runs once, on the train's head. Green fast-forwards `main` to it. +> - **Releases:** a train carries at most one release descriptor, and that member is last. The release is cut from the train's head as today. +> - **Failure:** a red train lands nothing. `land.cs` prints the train without its last member, so the lander can retry shorter. Members stay individually landable. +> - **Closings:** #209's check runs per member. Every member's `Completes:` issues need their trailers, and after the fast-forward every one is checked closed. +> - **Code:** `eng/land.cs -- --train N1 N2 …` builds and pushes the train head; the existing approval check runs per member. Fixtures: a clean two-member train; a member whose approval is for another sha (refused); a train with two descriptors (refused); a merge that conflicts (refused). +> - **Docs:** `AGENTS.md`'s landing section, `docs/releases.md`. +> +> This step pays back at once. Do it first. +> +> ### Step 2 — the chain graph and its gate +> +> Issue: "Chain branches: the feature tree, declared and checked". +> +> - `.chain/feature.ttl` on each `chain/<feature>` branch: its one parent (`main` at a release, or a chain branch at a stable tag) and its `touches`. +> - The graph is derived on every run from those files plus git facts, and is never a source of truth. +> - The rules are a SHACL shapes graph, with SPARQL-based constraints for depth (backstop 3), diamonds and acceptance, validated by `Varve.Shacl` and `Varve.Shacl.Sparql` at a **pinned** released version. +> - The checks are #230's list: one declared parent; the fork point is a parent stable tag; `main` is an ancestor; no diamond; the parent's decision set accepted at the forked tag; depth within the backstop; existing chain tags reachable; no rebase or force-push (the previous head is an ancestor of the new one). +> - A violating and a conforming fixture graph per shape, as for an analyzer rule. +> - The gate lives under `tools/` beside repo-standard, written so it can move to how-we-work: no Varve-specific path or name in its code, only in its configuration. +> +> ### Step 3 — the unstable-build marker +> +> Issue: "Storage format: a store written by a chain build is refused by a release build". Ruling 1 above. The format inventory entry (ADR 0216), `docs/spec/storage-format.md`, a test that a release build refuses a marked store with the existing reserved-byte refusal, and a test that a chain build refuses to write into an unmarked one. This lands before any chain build is published. +> +> ### Step 4 — stable tags and chain builds +> +> Issue: "Chain builds: stable tags, path-encoded versions, packages as a prerelease". +> +> - The tagging workflow of ruling 2. +> - Versions as #230 has them: `<base>.chain.<feature>.<build>[.<feature>.<build>]…`, read from the tag `chain/<version>`. Test the ordering against SemVer 2.0 §11 and against NuGet's comparer: a chain build sorts above its base preview and below the next one; a descendant sorts above its parent. Feature names are restricted to `[0-9a-z-]` with no leading zero in a numeric identifier; the gate refuses anything else. +> - A chain-build workflow that builds from a stable tag only, publishes `.nupkg` files and SBOMs as assets of a GitHub Release marked prerelease, and never pushes to nuget.org. It refuses a commit that is not a chain stable tag. The release workflow refuses `chain/*`. +> - The build sets the marker of step 3 because its version carries `.chain.`. +> - `docs/` for a consumer: how to restore a chain build from a GitHub Release. +> +> ### Step 5 — interference by trial merge +> +> Issue: "Chain branches: interference between siblings found by trial merge". As bounded above. A recorded interference blocks landing of the later of the two until one merges the other's parent forward or re-parents. It is also the evidence a landing train of siblings rests on, so step 1's `--train` consults it when both members are chain branches. +> +> ### Step 6 — allocators designed out +> +> Issue: "ADR numbers assigned at landing; `*.Unshipped.txt` merge by union". +> +> - On a branch, an ADR file is named by its decision set's key and carries no number; text cites keys. `land.cs` assigns the next free number at landing, renames the file and rewrites the references, in a commit marked `Landing-only: renumber` that it can reproduce. +> - `.gitattributes`: `merge=union` for `*.Unshipped.txt`. +> - The contributing guide, the session prompt template and the issue template (`parent`, `touches`). +> +> ## Not in this work +> +> - Migrating open pull requests to chain branches. +> - Replacing `land/` with gh-tt. That is its own ADR, and it inherits the no-squash constraint. +> - Agent grants (#246). Where a step needs "who may tag" or "who may land a train", it uses today's rule (ADR 0239) and says where #246 would change it. +> +> ## Close-out +> +> `docs/sessions/2026-11-<name>.md` and a `docs/traceability/` record per issue: pull requests, the ADR number taken, issues opened and closed, test counts before and after, questions, and anything deferred as a linked issue. + +## The report + +- **The issue log.** The prompt says AGENTS.md has the issue-log practice. On + `main` it does not yet; it is in #259 (ADR 0241's pull request), which this + branch stacks on. The session followed it: it commented on #230 when it + started and posted the plan there, opened #277 for step 1 and posted that + plan there, and filed #278 when it found ADR 0241 taken by two open pull + requests. +- **Base.** The branch forks from #261's head, not from `main`. This ADR adds + a dated note to 0102 after #261's amendment of 2026-11-10, and step 1 + extends #260's closing-trailer check. When #259–#261 land, the branch + merges `main` forward. +- **Number 0243.** The session checked every open pull request: 0240 is #268's, + 0241 is #259's and also #264's (#278), 0242 is #267's. +- **The ADR** is #230's text, with the review's four settled questions and + seven additions folded in. Each is marked *settled in review, 2026-11-11*, + and a table, **Differences from #230**, lists every change from the issue's + text. It has thirteen keys, all unaccepted. +- **Two things the session added**, for the maintainer to keep or strike: + - an alternative, GitHub's merge queue in place of trains, with the reason + it was not chosen; + - a revisit condition on trains. The ADR is accepted ahead of the evidence + that trains are faster, so it revisits if more than half of the first ten + trains land shorter, or the median train takes longer than two serial + landings. +- **The amendments.** + - 0088: `main` moves only by fast-forward, a merge commit on a branch is a + commit like any other, a chain branch never lands by squash, and a train + is one checked head. + - 0102: chain builds are outside a release's scope, the release workflow + refuses `chain/*`, and a release may ride last in a train. +- **Checks run:** `decision-sets` passes (220 sets, 1,528 decisions). No code + changes; test counts are unchanged. +- **Questions, posted on #230:** + 1. Should #230 close when this lands, with the rulings still unaccepted, or + should this wait for acceptance? + 2. Do you overrule the namespace before step 2? diff --git a/docs/traceability/2026-11-11-issue-277-landing-trains.md b/docs/traceability/2026-11-11-issue-277-landing-trains.md new file mode 100644 index 00000000..b5e9cb4f --- /dev/null +++ b/docs/traceability/2026-11-11-issue-277-landing-trains.md @@ -0,0 +1,66 @@ +# Landing trains (step 1 of the chain-builds work) + +> **Recorded contemporaneously**, by the session that did the work, under +> [ADR 0033](../adr/0033-commit-traceability.md). The prompt is verbatim in +> the step 0 record. The transcript itself is held by the maintainer. + +| | | +|---|---| +| **Issues** | [#277](https://github.com/Hafeok/Varve/issues/277), opened by this session for step 1; [#230](https://github.com/Hafeok/Varve/issues/230) and [#209](https://github.com/Hafeok/Varve/issues/209) referenced | +| **Date** | 2026-11-11 | +| **Tool** | Claude Code 2.1.322, a cloud session. Not the maintainer's delegate (ADR 0239 point 1) | +| **Model** | *Left for the maintainer.* 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_01NbWSYNzDrbT3ENstGnmH3N` | +| **Branch** | `claude/nice-mendel-495j4y-step1`, on step 0's branch at 5a4850b | +| **ADRs** | none decided; [0243](../adr/0243-chain-builds-ahead-of-main.md) point 10 (`LandingTrains`) built; [0087](../adr/0087-the-identity-map.md) and [0088](../adr/0088-only-checked-commits-reach-main.md) applied | + +## The prompt + +Step 1 of the session prompt, in +[the step 0 record](2026-11-11-issue-230-chain-builds-ahead-of-main.md), +under the message "Start with step 0 (the ADR) and step 1 (landing trains), +and post your plan on each issue before building." + +## The report + +- **The plan went on #277 before the code.** It named the part of the step + that is not `land.cs`: `agent review` admitted a `land/` push only when its + head *is* an approved pull request's head, and a train's head is a merge, + so every train would have been red there. `dco`, `issue-refs` and + `release-pending` already skip merges, and the release's head guard reads + `agent review`'s run, so neither needed a change. +- **`eng/land.cs -- --train N1 N2 …`:** + - checks each member before building anything: + - an `approve <sha>` naming its exact head is now a refusal, where a + single landing only warns; + - every check ruleset 1 requires must be concluded on the member's head; + - its `Completes:` line is checked against its own commits; + - at most one descriptor, which goes last, dated within three days; + - merges by `git merge-tree --write-tree` and `git commit-tree`, so no + working tree is touched and a conflict refuses the train. The merges are + dated at the newest commit they join, so building twice gives the same + head, and a fixture checks this; + - `--main` verifies the branch rather than rebuilding it: the first-parent + line down to `main`, each merge's trailer and tree, and the second + parents equal to the members' current heads; + - prints the train without its last member when the push to `main` is + refused, and without the member at fault when a merge conflicts; + - `--delegated-by <who>` adds ADR 0239 point 4's trailer to the merges. +- **`eng/agent-review.cs`** admits a train by the same rule, in its own copy. + The judge is main's, so a train cannot change how it is judged. On a push, + the workflow now checks main out with its whole history so that git can + read the pushed merges. This takes effect only once it is on `main`. +- **Measured:** + - `land-fixtures`: 12 cases → 22. Ten train cases are built for real in a + scratch bare repository: 2 pass, 8 are refused, 0 judged wrong. The + failure path was proven by copying the `pass-` case as `fail-`, which + gave "23 cases, 2 judged wrong", exit 1; + - `agent-review-tamper`: 4 new train cases, all hold; + - `licence-headers`, `land`: pass. +- **A real dry run against GitHub**, `land.cs -- --train 266 265 --print`, + refused both members, each for having no approval of its head, checks + still running, and no `Completes:` line. +- **Left for later steps or other issues:** + - consulting the trial-merge record when both members are chain branches + is step 5's; + - who may land a train is today's rule (ADR 0239). #246 would change it. diff --git a/eng/agent-review-tamper.cs b/eng/agent-review-tamper.cs index 814b3dd1..9c6fc64f 100644 --- a/eng/agent-review-tamper.cs +++ b/eng/agent-review-tamper.cs @@ -17,7 +17,11 @@ // and the check run main's copy posts is read back. The stand-in also // covers the approval form: 12 characters of the head pass, 11 fail, an // earlier head fails, a stranger fails, a review submission is not -// consulted; +// consulted; and a land/ push of a landing train (#277), built for real +// in a scratch repository: approved members pass, a member that is no +// approved head fails, a merge edited by hand fails, a merge without the +// trailer fails, an extra commit between the merges fails, and a train +// built on a main that has since moved fails; // 3. the workflow cannot run the tampered copy: the job that posts the // check in .github/workflows/agent-review.yml checks out main by name, // never a pull request ref; the workflow runs on no trigger whose @@ -161,6 +165,58 @@ (int exit, _) = RunJudge(root, server, "push", "{}", "refs/heads/land/first"); Require(exit == 0 && server.Posted is ["failure"], "main's copy, a land/ push of an approved sha that is no pull request's head: posts failure"); } + + // #277, ADR 0243 point 10: a landing train of approved heads, built for + // real in a scratch repository that GIT_DIR names, as eng/land.cs builds it. + string trains = Path.Combine(scratch, "trains.git"); + ScratchGit(trains, null, "init", "--quiet", "--bare", trains); + string trainMain = ScratchCommit(trains, [], new() { ["README.md"] = "Varve\n" }, "chore: main\n"); + string first = ScratchCommit(trains, [trainMain], new() { ["a.md"] = "a\n" }, "feat: a\n\nRefs #11\n"); + string second = ScratchCommit(trains, [trainMain], new() { ["b.md"] = "b\n" }, "feat: b\n\nRefs #12\n"); + string mergedFirst = ScratchMerge(trains, trainMain, first, "Merge #11 into the train for main\n\nLanding-only: merge\n", edit: false); + string trainHead = ScratchMerge(trains, mergedFirst, second, "Merge #12 into the train for main\n\nLanding-only: merge\n", edit: false); + string editedHead = ScratchMerge(trains, mergedFirst, second, "Merge #12 into the train for main\n\nLanding-only: merge\n", edit: true); + string unmarkedHead = ScratchMerge(trains, mergedFirst, second, "Merge #12 into the train for main\n", edit: false); + string extra = ScratchCommit(trains, [mergedFirst], new() { ["c.md"] = "c, which no member's head carries\n" }, "feat: c\n\nRefs #11\n"); + string extraHead = ScratchMerge(trains, extra, second, "Merge #12 into the train for main\n\nLanding-only: merge\n", edit: false); + string movedMain = ScratchCommit(trains, [trainMain], new() { ["d.md"] = "d\n" }, "chore: main moved on\n"); + Dictionary<string, string> inScratch = new() { ["GIT_DIR"] = trains }; + + using (StandIn server = StandIn.Start(identities, trainHead, "Hafeok", [], [], approvedBy: GatesApp, main: trainMain, pulls: new() { [first] = 11, [second] = 12 })) + { + (int exit, string output) = RunJudge(root, server, "push", "{}", "refs/heads/land/train-11-12", trainHead, inScratch); + Require(exit == 0 && server.Posted is ["success"], "main's copy, a land/ push of a train of approved heads: posts success" + (server.Posted is ["success"] ? "" : $"\n{output}")); + } + + using (StandIn server = StandIn.Start(identities, trainHead, "Hafeok", [], [], approvedBy: GatesApp, main: trainMain, pulls: new() { [first] = 11 })) + { + (int exit, string output) = RunJudge(root, server, "push", "{}", "refs/heads/land/train-11-12", trainHead, inScratch); + Require(exit == 0 && server.Posted is ["failure"] && output.Contains("is not an approved pull request's head", StringComparison.Ordinal), "main's copy, a train with a member that is no approved pull request's head: posts failure, saying it is not an approved pull request's head" + (output.Contains("is not an approved pull request's head", StringComparison.Ordinal) ? "" : $"\n{output}")); + } + + using (StandIn server = StandIn.Start(identities, editedHead, "Hafeok", [], [], approvedBy: GatesApp, main: trainMain, pulls: new() { [first] = 11, [second] = 12 })) + { + (int exit, string output) = RunJudge(root, server, "push", "{}", "refs/heads/land/train-11-12", editedHead, inScratch); + Require(exit == 0 && server.Posted is ["failure"] && output.Contains("is not `git merge-tree` of its parents under the pinned settings", StringComparison.Ordinal), "main's copy, a train whose merge was edited by hand: posts failure, saying it is not `git merge-tree` of its parents under the pinned settings" + (output.Contains("is not `git merge-tree` of its parents under the pinned settings", StringComparison.Ordinal) ? "" : $"\n{output}")); + } + + using (StandIn server = StandIn.Start(identities, unmarkedHead, "Hafeok", [], [], approvedBy: GatesApp, main: trainMain, pulls: new() { [first] = 11, [second] = 12 })) + { + (int exit, string output) = RunJudge(root, server, "push", "{}", "refs/heads/land/train-11-12", unmarkedHead, inScratch); + Require(exit == 0 && server.Posted is ["failure"] && output.Contains("has no `Landing-only: merge` trailer", StringComparison.Ordinal), "main's copy, a train whose merge has no `Landing-only: merge` trailer: posts failure, saying it has no `Landing-only: merge` trailer" + (output.Contains("has no `Landing-only: merge` trailer", StringComparison.Ordinal) ? "" : $"\n{output}")); + } + + using (StandIn server = StandIn.Start(identities, extraHead, "Hafeok", [], [], approvedBy: GatesApp, main: trainMain, pulls: new() { [first] = 11, [second] = 12 })) + { + (int exit, string output) = RunJudge(root, server, "push", "{}", "refs/heads/land/train-11-12", extraHead, inScratch); + Require(exit == 0 && server.Posted is ["failure"] && output.Contains("is not a merge of two parents", StringComparison.Ordinal), "main's copy, a train with an extra commit on the first-parent line between its merges: posts failure, saying it is not a merge of two parents" + (output.Contains("is not a merge of two parents", StringComparison.Ordinal) ? "" : $"\n{output}")); + } + + using (StandIn server = StandIn.Start(identities, trainHead, "Hafeok", [], [], approvedBy: GatesApp, main: movedMain, pulls: new() { [first] = 11, [second] = 12 })) + { + (int exit, string output) = RunJudge(root, server, "push", "{}", "refs/heads/land/train-11-12", trainHead, inScratch); + Require(exit == 0 && server.Posted is ["failure"] && output.Contains("is not a merge of two parents, and main is not below it", StringComparison.Ordinal), "main's copy, a train built on a main that has since moved: posts failure, saying it is not a merge of two parents, and main is not below it" + (output.Contains("is not a merge of two parents, and main is not below it", StringComparison.Ordinal) ? "" : $"\n{output}")); + } } finally { @@ -212,7 +268,8 @@ void Require(bool holds, string what) // Runs <directory>/eng/agent-review.cs as the judge job runs it, on // pull_request_target, against the stand-in. static (int Exit, string Output) RunJudge( - string directory, StandIn server, string eventName = "pull_request_target", string eventJson = """{"pull_request":{"number":7}}""", string? reference = null) + string directory, StandIn server, string eventName = "pull_request_target", string eventJson = """{"pull_request":{"number":7}}""", string? reference = null, + string sha = "1234567890abcdef1234567890abcdef12345678", Dictionary<string, string>? environment = null) { string payload = Path.Combine(Path.GetTempPath(), $"agent-review-event-{Guid.NewGuid():N}.json"); File.WriteAllText(payload, eventJson); @@ -228,7 +285,12 @@ void Require(bool holds, string what) start.ArgumentList.Add("run"); start.ArgumentList.Add("eng/agent-review.cs"); start.Environment["GITHUB_EVENT_NAME"] = eventName; - start.Environment["GITHUB_SHA"] = "1234567890abcdef1234567890abcdef12345678"; + start.Environment["GITHUB_SHA"] = sha; + + foreach ((string key, string value) in environment ?? []) + { + start.Environment[key] = value; + } if (reference is not null) { @@ -250,6 +312,76 @@ void Require(bool holds, string what) return (process.ExitCode, output + error.Result); } +// git in a scratch repository, with standard input; the output, trimmed. +static string ScratchGit(string gitDir, string? input, params string[] arguments) +{ + ProcessStartInfo start = new("git") + { + RedirectStandardInput = input is not null, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + }; + + foreach (string argument in arguments) + { + start.ArgumentList.Add(argument); + } + + start.Environment["GIT_DIR"] = gitDir; + start.Environment["GIT_INDEX_FILE"] = gitDir + ".index"; + start.Environment["GIT_AUTHOR_NAME"] = start.Environment["GIT_COMMITTER_NAME"] = "Fixture"; + start.Environment["GIT_AUTHOR_EMAIL"] = start.Environment["GIT_COMMITTER_EMAIL"] = "fixture@example.org"; + + using Process process = Process.Start(start) ?? throw new InvalidOperationException("Could not start git."); + if (input is not null) + { + process.StandardInput.Write(input); + process.StandardInput.Close(); + } + + Task<string> error = process.StandardError.ReadToEndAsync(); + string output = process.StandardOutput.ReadToEnd(); + process.WaitForExit(); + return process.ExitCode is 0 or 1 ? output.Trim() : throw new InvalidOperationException($"git {string.Join(' ', arguments)}: {error.Result}"); +} + +// A commit of `files` over the first parent's tree. +static string ScratchCommit(string gitDir, string[] parents, Dictionary<string, string> files, string message) +{ + File.Delete(gitDir + ".index"); + if (parents.Length > 0) + { + ScratchGit(gitDir, null, "read-tree", $"{parents[0]}^{{tree}}"); + } + + foreach ((string path, string content) in files) + { + string blob = ScratchGit(gitDir, content, "hash-object", "-w", "--stdin"); + ScratchGit(gitDir, null, "update-index", "--add", "--cacheinfo", $"100644,{blob},{path}"); + } + + string tree = ScratchGit(gitDir, null, "write-tree"); + return ScratchGit(gitDir, message, ["commit-tree", tree, .. parents.SelectMany(static parent => new[] { "-p", parent }), "-F", "-"]); +} + +// A merge whose tree is `git merge-tree` of its parents, or, with `edit`, +// that tree with a file added by hand. +static string ScratchMerge(string gitDir, string ours, string theirs, string message, bool edit) +{ + string tree = ScratchGit(gitDir, null, "merge-tree", "--write-tree", "--name-only", "--no-messages", ours, theirs).Split('\n')[0]; + if (edit) + { + File.Delete(gitDir + ".index"); + ScratchGit(gitDir, null, "read-tree", tree); + string blob = ScratchGit(gitDir, "a line nobody reviewed\n", "hash-object", "-w", "--stdin"); + ScratchGit(gitDir, null, "update-index", "--add", "--cacheinfo", $"100644,{blob},unreviewed.txt"); + tree = ScratchGit(gitDir, null, "write-tree"); + } + + return ScratchGit(gitDir, message, "commit-tree", tree, "-p", ours, "-p", theirs, "-F", "-"); +} + // The text of a top-level key's block (`on:` and what it holds), comments out. static string TopLevelBlock(string workflow, string key) { @@ -328,13 +460,18 @@ sealed class StandIn : IDisposable private readonly string commitEmail; private readonly long? approvedBy; private readonly bool pullHead; + private readonly string main; + private readonly Dictionary<string, int> pulls; public List<string> Posted { get; } = []; public string Url { get; } - private StandIn(string identities, string head, string author, JsonArray comments, JsonArray reviews, string commitEmail, long? approvedBy, bool pullHead) + private StandIn(string identities, string head, string author, JsonArray comments, JsonArray reviews, string commitEmail, long? approvedBy, bool pullHead, + string main, Dictionary<string, int> pulls) { + this.main = main; + this.pulls = pulls; this.commitEmail = commitEmail; this.approvedBy = approvedBy; this.pullHead = pullHead; @@ -355,9 +492,9 @@ private StandIn(string identities, string head, string author, JsonArray comment public static StandIn Start( string identities, string head, string author, JsonArray comments, JsonArray reviews, string commitEmail = "noreply@anthropic.com", - long? approvedBy = null, bool pullHead = false) + long? approvedBy = null, bool pullHead = false, string main = "0000000000000000000000000000000000000000", Dictionary<string, int>? pulls = null) { - StandIn server = new(identities, head, author, comments, reviews, commitEmail, approvedBy, pullHead); + StandIn server = new(identities, head, author, comments, reviews, commitEmail, approvedBy, pullHead, main, pulls ?? []); server.listener.Start(); _ = Task.Run(server.Serve); return server; @@ -441,12 +578,23 @@ private async Task Serve() }.ToJsonString() : """{"total_count":0,"check_runs":[]}"""; } + else if (path.EndsWith("/commits/main", StringComparison.Ordinal)) + { + body = new JsonObject { ["sha"] = main }.ToJsonString(); + } else if (path.EndsWith($"/commits/{head}/pulls", StringComparison.Ordinal)) { body = firstPage && pullHead ? new JsonArray(new JsonObject { ["number"] = 7, ["head"] = new JsonObject { ["sha"] = head } }).ToJsonString() : "[]"; } + else if (Regex.Match(path, @"/commits/(?<sha>[0-9a-f]{40})/pulls$") is { Success: true } member) + { + // A train's member: its pull request, or none, as GitHub answers. + body = firstPage && pulls.TryGetValue(member.Groups["sha"].Value, out int number) + ? new JsonArray(new JsonObject { ["number"] = number, ["head"] = new JsonObject { ["sha"] = member.Groups["sha"].Value } }).ToJsonString() + : "[]"; + } else { status = 404; diff --git a/eng/agent-review.cs b/eng/agent-review.cs index 3af710e6..8a7e45a3 100644 --- a/eng/agent-review.cs +++ b/eng/agent-review.cs @@ -46,6 +46,15 @@ // approved head by pushing it to land/<name> and fast-forwarding // main to it, and the merged head is the reviewed head. Posts // the same check. +// Or a landing train (#277, ADR 0243 point 10): a head whose +// first-parent line down to main is only `Landing-only: merge` +// commits of two parents, each one's tree `git merge-tree` of +// its parents, each one's second parent an approved pull +// request's head, by the same test, and two or more of them. +// The workflow fetches the history for a push, as data; git +// reads it, and nothing from it is built or run. The +// approvals are the members' own, so nothing on the land/ +// branch can add one, and no hand-resolved merge passes. // none a local run: says what it would need and exits 0. // // GITHUB_API_URL points it at a stand-in, which is how eng/agent-review-tamper.cs @@ -61,6 +70,7 @@ #:include lib/Identities.cs #:property NoWarn=$(NoWarn);CA2266 +using System.Diagnostics; using System.Net.Http.Headers; using System.Text; using System.Text.Json; @@ -307,6 +317,24 @@ } } +// A land/ push that is no approved head may be a landing train of approved +// heads (#277, ADR 0243 point 10). +List<int>? train = null; +string notATrain = ""; + +if (pullNumber is null && agents.Count > 0 && approvedPull is null) +{ + try + { + (train, notATrain) = await ApprovedTrain(api, headSha, PinnedIntegration(repositoryRoot, checkName), checkName); + } + catch (Exception exception) when (exception is HttpRequestException or JsonException or KeyNotFoundException or InvalidOperationException or TaskCanceledException) + { + Console.Error.WriteLine($"agent-review: could not read whether {headSha} is a train of approved heads: {exception.Message}"); + return 2; + } +} + foreach (Agent agent in agents.Values) { IReadOnlyList<Human> holders = baseMap.ActingFor(agent); @@ -320,8 +348,14 @@ continue; } + if (train is not null) + { + Console.WriteLine($"ok '{agent.Id}' authored commits here, and {headSha[..MinimumShaPrefix]} is a train of the approved heads of {string.Join(", ", train.Select(static number => $"#{number}"))}"); + continue; + } + findings.Add($"'{agent.Id}' authored commits on this branch, and {headSha[..MinimumShaPrefix]} is not the head of a pull request " - + $"approved by {who}; push an approved pull request's head, unchanged"); + + $"approved by {who}, nor a train of approved heads ({notATrain}); push an approved pull request's head, unchanged, or a train eng/land.cs --train built"); continue; } @@ -422,6 +456,130 @@ static void AddCommit(JsonElement commit, List<string> emails) return null; } +// A landing train (#277): the pull requests whose approved heads it merges, +// in order, or null and why not. eng/land.cs's Trains.Verify is the same rule; +// this copy is main's, so a train cannot change how it is judged. +static async Task<(List<int>? Members, string Why)> ApprovedTrain(Api api, string head, long? integration, string name) +{ + if (integration is null) + { + return (null, "no App is pinned for the check"); + } + + string main; + using (JsonDocument branch = await api.Get("commits/main")) + { + main = branch.RootElement.GetProperty("sha").GetString()!; + } + + string infoAttributes = Git("rev-parse", "--git-path", "info/attributes").Output.Trim(); + + if (File.Exists(infoAttributes) && File.ReadAllLines(infoAttributes).Any(static line => line.Trim() is { Length: > 0 } text && !text.StartsWith('#'))) + { + return (null, $"{infoAttributes} sets attributes, which change how a merge resolves"); + } + + List<int> members = []; + string at = head; + + while (at != main) + { + if (members.Count >= 100) + { + return (null, "its first-parent line does not reach main within 100 merges"); + } + + (int exit, string parents) = Git("rev-list", "--parents", "-n", "1", at); + string[] shas = parents.Trim().Split(' '); + + if (exit != 0) + { + return (null, $"{at[..MinimumShaPrefix]} is not in the checkout"); + } + + if (shas.Length != 3) + { + return (null, $"{at[..MinimumShaPrefix]} is not a merge of two parents, and main is not below it on its first-parent line"); + } + + if (!Regex.IsMatch(Git("log", "-1", "--format=%B", at).Output, @"^Landing-only:[ \t]*merge[ \t]*$", RegexOptions.Multiline | RegexOptions.CultureInvariant)) + { + return (null, $"{at[..MinimumShaPrefix]} has no `Landing-only: merge` trailer"); + } + + (int mergeExit, string merged) = PinnedMergeTree(shas[1], shas[2]); + + if (mergeExit != 0 || merged.Split('\n')[0].Trim() != Git("rev-parse", $"{at}^{{tree}}").Output.Trim()) + { + return (null, $"{at[..MinimumShaPrefix]}'s tree is not `git merge-tree` of its parents under the pinned settings"); + } + + if (await ApprovedPullRequestHead(api, shas[2], integration, name) is not int member) + { + return (null, $"{shas[2][..MinimumShaPrefix]}, which {at[..MinimumShaPrefix]} merges, is not an approved pull request's head"); + } + + members.Insert(0, member); + at = shas[1]; + } + + return members.Count < 2 ? (null, "a train has two or more members") : (members, ""); +} + +// `git merge-tree` under the settings every train merge runs under (ADR 0243 +// point 10, the merge is pinned). eng/land.cs's Trains.MergeTree states the +// same; change both together. Attributes come from the train so far (the +// first parent), never main's checkout, with the global and system files off; +// renames are fixed. A different git that merges differently refuses the +// train, and the lander builds it again. +static (int Exit, string Output) PinnedMergeTree(string ours, string theirs) => + GitWith(new Dictionary<string, string> { ["GIT_ATTR_NOSYSTEM"] = "1" }, + "-c", "core.attributesFile=", + "-c", "merge.renames=true", + "-c", "merge.directoryRenames=false", + "-c", "merge.renameLimit=7000", + "-c", "diff.renames=true", + "-c", "diff.renameLimit=7000", + $"--attr-source={ours}", "merge-tree", "--write-tree", "--name-only", "--no-messages", ours, theirs); + +// git in the working directory (main's checkout, which holds the pushed +// history for a push), or GIT_DIR. Read only. +static (int Exit, string Output) Git(params string[] arguments) => GitWith(null, arguments); + +static (int Exit, string Output) GitWith(Dictionary<string, string>? environment, params string[] arguments) +{ + ProcessStartInfo start = new("git") + { + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + }; + + foreach ((string key, string value) in environment ?? []) + { + start.Environment[key] = value; + } + + foreach (string argument in arguments) + { + start.ArgumentList.Add(argument); + } + + try + { + using Process process = Process.Start(start) ?? throw new InvalidOperationException("could not start git"); + Task<string> error = process.StandardError.ReadToEndAsync(); + string output = process.StandardOutput.ReadToEnd(); + process.WaitForExit(); + _ = error.Result; + return (process.ExitCode, output); + } + catch (System.ComponentModel.Win32Exception) + { + return (127, ""); + } +} + // The integration id ruleset 1 pins the check to, read from main's // .github/repo-standard.yaml; the check run of that App is the one updated. static long? PinnedIntegration(string root, string name) diff --git a/eng/ci.cs b/eng/ci.cs index fcdfd6a4..bc3df015 100644 --- a/eng/ci.cs +++ b/eng/ci.cs @@ -119,6 +119,16 @@ ("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, + // and its landing trains, built in a scratch repository, on every run + // (#209, #277, 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, reports an issue left open after landing, and builds, refuses and verifies landing trains, 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..38ef0c9d 100644 --- a/eng/land.cs +++ b/eng/land.cs @@ -8,8 +8,13 @@ // dotnet run eng/land.cs -- <pr> --main fast-forward main to it, then delete land/<name> // dotnet run eng/land.cs -- --commit <rev> the same for a commit of your own // +// dotnet run eng/land.cs -- --train <pr> <pr> ... build a train of approved heads and push it to land/<name> +// dotnet run eng/land.cs -- --train <pr> <pr> ... --main fast-forward main to that train +// // --name <name> the land/ branch's name, in place of the one worked out // --print print the git commands, run none +// --delegated-by <who> a train's merges carry `Delegated-by: <who>` (ADR 0239 point 4) +// --fixtures <dir> judge the closing-trailer and train 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 +40,48 @@ // `--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. +// +// Landing trains (#277, ADR 0243 point 10, `LandingTrains`). A train is one +// head that merges two or more pull request heads onto main, in the order +// given, so that every gate runs once for all of them: +// - each member's head must be approved for its exact sha (an `approve <sha>` +// comment, refused here rather than warned, because the train's verdict +// rests on each member's own), and green on its own: every check ruleset 1 +// requires (.github/repo-standard.yaml) concluded on that head; +// - each member's `Completes:` line is checked against its own commits; +// - at most one member adds a release descriptor, and it goes last, so the +// land/ branch is land/release-<version> and the release is cut from the +// train's head as for any landing; +// - each merge is `git merge-tree --write-tree` of the train so far and the +// member, committed with `git commit-tree` and a `Landing-only: merge` +// trailer, dated at the newest of the commits it joins, so the same members +// on the same main give the same head. A conflict refuses the train: a +// merge is never resolved by hand. +// `--main` re-reads land/<name>, checks that its first-parent line down to +// main is exactly those merges, each reproducible, with the members' current +// heads as second parents, and fast-forwards main to it. When the push is +// refused, or a merge conflicts, it prints the train without its last member. +// `agent review` admits the train's head on the same terms (eng/agent-review.cs). +// +// 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 +89,7 @@ #:property NoWarn=$(NoWarn);CA2266 using System.Diagnostics; +using System.Text.Json; using System.Text.RegularExpressions; const string Remote = "origin"; @@ -52,6 +99,9 @@ string? name = null; bool toMain = false; bool print = false; +string? fixtures = null; +List<int>? train = null; +string? delegatedBy = null; for (int i = 0; i < args.Length; i++) { @@ -69,13 +119,30 @@ case "--name" when i + 1 < args.Length: name = args[++i]; break; + case "--fixtures" when i + 1 < args.Length: + fixtures = args[++i]; + break; + case "--train": + train = []; + break; + case "--delegated-by" when i + 1 < args.Length: + delegatedBy = args[++i]; + break; case "--help" or "-h": Usage(); return 0; default: if (int.TryParse(args[i].TrimStart('#'), out int number) && number > 0) { - pullRequest = number; + if (train is not null) + { + train.Add(number); + } + else + { + pullRequest = number; + } + break; } @@ -85,6 +152,30 @@ } } +if (fixtures is not null) +{ + return Fixtures.Run(fixtures); +} + +if (train is not null) +{ + if (commit is not null) + { + Console.Error.WriteLine("land: --train takes pull request numbers, not --commit"); + return 2; + } + + try + { + return LandTrain(train, toMain, print, name, delegatedBy); + } + catch (GitException exception) + { + Console.Error.WriteLine($"land: {exception.Message}"); + return 2; + } +} + if (pullRequest is null == commit is null) { Console.Error.WriteLine("land: give a pull request number or --commit <rev>, one of them"); @@ -125,6 +216,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 +306,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 +320,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) { @@ -225,6 +454,184 @@ static void WarnWithoutApproval(int number, string head) Console.WriteLine($"land: warning: no `approve {head[..12]}` comment on #{number}. If it has an agent's commits, `agent review` will fail on land/ until there is one."); } +// A train (#277): build it and push it to land/<name>, or, with --main, +// fast-forward main to the train already checked there. +static int LandTrain(List<int> numbers, bool toMain, bool print, string? name, string? delegatedBy) +{ + Git("fetch", "--quiet", "--no-tags", Remote, "refs/heads/main"); + string main = Git("rev-parse", "FETCH_HEAD"); + string[] required = Trains.RequiredChecks(File.ReadAllText(Path.Combine(Git("rev-parse", "--show-toplevel"), ".github", "repo-standard.yaml"))); + + List<TrainMember> members = []; + foreach (int number in numbers) + { + Git("fetch", "--quiet", "--no-tags", Remote, $"refs/pull/{number}/head"); + string head = Git("rev-parse", "FETCH_HEAD"); + string? body = PullRequestBody(number); + List<string>? approvals = Approvals(number); + Dictionary<string, string>? checks = CheckConclusions(head); + if (body is null || approvals is null || checks is null) + { + Console.Error.WriteLine($"land: could not read #{number}'s body, comments or checks with the GitHub CLI, so the train cannot be checked; refusing rather than skipping it."); + return 2; + } + + members.Add(new TrainMember(number, head, approvals, Trains.Unmet(required, checks), body, + PushedCommits(main, head), Trains.AddedDescriptors(main, head))); + } + + List<string> refusals = Trains.Check(members); + DateOnly landingDay = DateOnly.FromDateTime(DateTime.UtcNow); + foreach (TrainMember member in members) + { + foreach ((string version, string date) in PendingDescriptors(main, member.Head)) + { + if (Dates.Check(version, date, landingDay) is string refusal) + { + refusals.Add($"#{member.Number}: {refusal}"); + } + } + } + + if (refusals.Count > 0) + { + foreach (string refusal in refusals) + { + Console.Error.WriteLine($"land: {refusal}"); + } + + return 1; + } + + name ??= Trains.Name(members); + string land = $"refs/heads/land/{name}"; + string order = string.Join(" ", numbers); + + if (!toMain) + { + List<string> trailers = []; + if (delegatedBy is not null) + { + trailers.Add($"Delegated-by: {delegatedBy}"); + } + + string lander = Git("config", "user.email"); + (int signExit, string signOff) = Try("dotnet", "run", Path.Combine(Git("rev-parse", "--show-toplevel"), "eng", "dco.cs"), "--", "--sign-off-for", lander); + if (signExit == 0 && signOff.Trim().StartsWith("Signed-off-by: ", StringComparison.Ordinal)) + { + trailers.Add(signOff.Trim()); + } + + (string? head, string? conflict, int at) = Trains.Build(main, members, trailers); + if (head is null) + { + Console.Error.WriteLine($"land: {conflict}"); + if (at >= 0) + { + Console.Error.WriteLine(Trains.Shorter(numbers, at)); + } + return 1; + } + + Console.WriteLine($"land: the train {string.Join(", ", numbers.Select(static n => $"#{n}"))} onto main at {main[..12]} is {head[..12]}."); + ListClosings(Closings.Trailers(PushedCommits(main, head))); + + if (RemoteHead(land) == head) + { + Console.WriteLine($"land: land/{name} is at {head[..12]} already; its checks are running or done."); + } + else + { + Run(print, "git", "push", Remote, $"{head}:{land}"); + } + + Console.WriteLine(); + Console.WriteLine($"Next, once the checks on land/{name} are green:"); + Console.WriteLine($" dotnet run eng/land.cs -- --train {order}{(name != Trains.Name(members) ? $" --name {name}" : "")} --main"); + return 0; + } + + string? landed = RemoteHead(land); + if (landed is null) + { + Console.Error.WriteLine($"land: there is no land/{name} on {Remote}; push the train first (without --main) and let its checks run"); + return 1; + } + + (List<string>? joined, string? why) = Trains.Verify(main, landed); + if (joined is null) + { + Console.Error.WriteLine($"land: land/{name} at {landed[..12]} is not a train onto main at {main[..12]}: {why}. Build it again, without --main."); + return 1; + } + + if (!joined.SequenceEqual(members.Select(static member => member.Head))) + { + Console.Error.WriteLine($"land: land/{name} at {landed[..12]} joins {string.Join(", ", joined.Select(static sha => sha[..12]))}, not the members' heads now; a head moved since the train was built. Build it again, without --main."); + return 1; + } + + List<Trailer> closing = Closings.Trailers(PushedCommits(main, landed)); + ListClosings(closing); + + if (Run(print, "git", "push", Remote, $"{landed}:refs/heads/main") != 0) + { + Console.Error.WriteLine($"land: {Remote} refused to move main. If a required check on land/{name} has not passed yet, wait for it and run this again. If one failed, the train lands nothing; its members stay landable on their own."); + Console.Error.WriteLine(Trains.Shorter(numbers, numbers.Count - 1)); + return 1; + } + + Run(print, "git", "push", Remote, "--delete", land); + if (print) + { + return 0; + } + + Console.WriteLine(); + Console.WriteLine($"land: main is at {landed[..12]}, the train {string.Join(", ", numbers.Select(static n => $"#{n}"))}.{(name.StartsWith("release-", StringComparison.Ordinal) ? " The release workflow now cuts the release it carries." : "")}"); + (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; +} + +// The shas a pull request's `approve <sha>` comments name, or null when +// GitHub cannot be read. Whose comment it is stays `agent review`'s verdict. +static List<string>? Approvals(int number) +{ + (int exit, string output) = Try("gh", "api", "--paginate", $"repos/{{owner}}/{{repo}}/issues/{number}/comments", "--jq", ".[].body"); + return exit != 0 + ? null + : [.. Regex.Matches(output, @"\bapprove\s+([0-9a-f]{12,40})\b", RegexOptions.IgnoreCase).Select(static match => match.Groups[1].Value.ToLowerInvariant())]; +} + +// Each check's latest conclusion on a commit, by name, or null when GitHub +// cannot be read. A run still going has no conclusion and reads "pending". +static Dictionary<string, string>? CheckConclusions(string sha) +{ + (int exit, string output) = Try("gh", "api", "--paginate", $"repos/{{owner}}/{{repo}}/commits/{sha}/check-runs?filter=latest", + "--jq", ".check_runs[] | \"\\(.name)\\t\\(.conclusion // \"pending\")\""); + if (exit != 0) + { + return null; + } + + Dictionary<string, string> conclusions = []; + foreach (string line in output.Split('\n', StringSplitOptions.RemoveEmptyEntries)) + { + string[] parts = line.Split('\t'); + if (parts.Length == 2 && (!conclusions.TryGetValue(parts[0], out string? seen) || seen != "success")) + { + conclusions[parts[0]] = parts[1]; + } + } + + return conclusions; +} + static int Run(bool print, string file, params string[] arguments) { Console.WriteLine($"$ {file} {string.Join(' ', arguments)}"); @@ -285,9 +692,625 @@ static void Usage() => Console.Error.WriteLine(""" dotnet run eng/land.cs -- <pr> push the pull request's head to land/<name> dotnet run eng/land.cs -- <pr> --main fast-forward main to it, then delete land/<name> dotnet run eng/land.cs -- --commit <rev> the same for a commit of your own + dotnet run eng/land.cs -- --train <pr> <pr> ... build a train of approved heads, push it to land/<name> + dotnet run eng/land.cs -- --train <pr> <pr> ... --main fast-forward main to that train options: --name <name> the land/ branch's name, in place of the one worked out --print print the git commands, run none + --delegated-by <who> a train's merges carry `Delegated-by: <who>` (ADR 0239 point 4) + --fixtures <dir> judge the closing-trailer and train 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}"; + } +} + +// A member of a train: its pull request, head, the shas its approvals name, +// the required checks not green on its head, its body, its own commits over +// main, and the release descriptors it adds. +sealed record TrainMember( + int Number, + string Head, + List<string> Approvals, + List<string> Unmet, + string Body, + List<(string Sha, string Message)> Commits, + List<string> Descriptors); + +// Landing trains (#277, ADR 0243 point 10). The checks are over plain values; +// Build and Verify run git, in whatever repository GIT_DIR or the working +// directory names, so that the fixtures run them on a scratch repository. +static class Trains +{ + public const string MergeTrailer = "Landing-only: merge"; + + static readonly Regex LandingOnlyMerge = new(@"^Landing-only:[ \t]*merge[ \t]*$", RegexOptions.Multiline | RegexOptions.CultureInvariant); + + // Every context ruleset 1 requires, read from .github/repo-standard.yaml. + public static string[] RequiredChecks(string standard) + { + int start = standard.IndexOf("required_status_checks:", StringComparison.Ordinal); + return start < 0 + ? [] + : [.. Regex.Matches(standard[start..], @"^\s*-\s*context:\s*(?<name>.+?)\s*$", RegexOptions.Multiline) + .Select(static match => match.Groups["name"].Value.Trim('"', '\''))]; + } + + // The required checks not concluded success (or skipped or neutral) on a head. + public static List<string> Unmet(string[] required, Dictionary<string, string> conclusions) => + [.. required.Where(check => !conclusions.TryGetValue(check, out string? conclusion) || conclusion is not ("success" or "skipped" or "neutral")) + .Select(check => conclusions.TryGetValue(check, out string? conclusion) ? $"{check} ({conclusion})" : $"{check} (not run)")]; + + // The versions of the release descriptors a head adds over main. + public static List<string> AddedDescriptors(string main, string head) => + [.. Shell.Git("diff", "--name-only", "--diff-filter=A", main, head, "--", "releases/") + .Split('\n', StringSplitOptions.RemoveEmptyEntries) + .Select(static path => Regex.Match(path, @"^releases/(v[^/]+)\.yaml$")) + .Where(static match => match.Success) + .Select(static match => match.Groups[1].Value)]; + + // The settings every train merge runs under (ADR 0243 point 10, the merge + // is pinned). eng/agent-review.cs states the same; change both together. + // Attributes come from the train so far, never a working tree, with the + // global and system files off; renames are fixed. + static readonly string[] PinnedMergeSettings = + [ + "-c", "core.attributesFile=", + "-c", "merge.renames=true", + "-c", "merge.directoryRenames=false", + "-c", "merge.renameLimit=7000", + "-c", "diff.renames=true", + "-c", "diff.renameLimit=7000", + ]; + + public static (int Exit, string Output) MergeTree(string ours, string theirs) => + Shell.Try(new() { ["GIT_ATTR_NOSYSTEM"] = "1" }, null, "git", + [.. PinnedMergeSettings, $"--attr-source={ours}", "merge-tree", "--write-tree", "--name-only", "--no-messages", ours, theirs]); + + // A repository-local info/attributes changes how a merge resolves and no + // option switches it off, so it refuses the train. Null when there is none. + public static string? LocalAttributes() + { + string path = Shell.Git("rev-parse", "--git-path", "info/attributes"); + return File.Exists(path) && File.ReadAllLines(path).Any(static line => line.Trim() is { Length: > 0 } text && !text.StartsWith('#')) + ? $"{path} sets attributes, which change how a merge resolves; a train is built and judged without them (ADR 0243 point 10). Move them into .gitattributes or remove them." + : null; + } + + // What refuses the train before it is built. Empty means it may be built. + public static List<string> Check(List<TrainMember> members) + { + List<string> refusals = []; + if (members.Count < 2) + { + string alone = members.Count == 1 ? $" Land #{members[0].Number} alone: dotnet run eng/land.cs -- {members[0].Number}" : ""; + return [$"a train has two or more members.{alone}"]; + } + + foreach (IGrouping<int, TrainMember> twice in members.GroupBy(static member => member.Number).Where(static group => group.Count() > 1)) + { + refusals.Add($"#{twice.Key} is in the train {twice.Count()} times; name each member once."); + } + + foreach (TrainMember member in members) + { + string head = member.Head[..12]; + if (!member.Approvals.Any(approved => member.Head.StartsWith(approved, StringComparison.Ordinal))) + { + string others = member.Approvals.Count == 0 ? "it has none" : $"its approvals are for {string.Join(", ", member.Approvals.Distinct().Select(static sha => sha.Length > 12 ? sha[..12] : sha))}"; + refusals.Add($"#{member.Number}'s head {head} has no `approve {head}`; {others}. A train lands only heads approved as they are."); + } + + if (member.Unmet.Count > 0) + { + refusals.Add($"#{member.Number}'s head {head} is not green on its own: {string.Join(", ", member.Unmet)}."); + } + + foreach (string refusal in Closings.CheckCompletes(member.Body, Closings.Trailers(member.Commits))) + { + refusals.Add($"#{member.Number}: {refusal}"); + } + } + + List<TrainMember> releasing = [.. members.Where(static member => member.Descriptors.Count > 0)]; + if (releasing.Count > 1 || releasing.Any(static member => member.Descriptors.Count > 1)) + { + refusals.Add($"the train carries {releasing.Sum(static member => member.Descriptors.Count)} release descriptors (" + + string.Join(", ", releasing.SelectMany(static member => member.Descriptors.Select(version => $"#{member.Number}: {version}"))) + + "); a train carries at most one."); + } + else if (releasing.Count == 1 && releasing[0] != members[^1]) + { + refusals.Add($"#{releasing[0].Number} carries the release descriptor {releasing[0].Descriptors[0]}, so it goes last in the train."); + } + + return refusals; + } + + // land/release-<version> when the last member carries a descriptor, so + // that the push dry-runs the release; otherwise land/train-<N>-<N>-... + public static string Name(List<TrainMember> members) => + members[^1].Descriptors is [string version] + ? $"release-{version}" + : "train-" + string.Join("-", members.Select(static member => member.Number)); + + // Merges each member's head onto the train so far, from main, by + // `git merge-tree`. The head, or null and the conflict. + public static (string? Head, string? Conflict, int At) Build(string main, List<TrainMember> members, List<string> trailers) + { + string date = members.Select(static member => member.Head).Append(main) + .Select(static sha => long.Parse(Shell.Git("log", "-1", "--format=%ct", sha), System.Globalization.CultureInfo.InvariantCulture)) + .Max().ToString(System.Globalization.CultureInfo.InvariantCulture); + Dictionary<string, string> dated = new() { ["GIT_AUTHOR_DATE"] = $"{date} +0000", ["GIT_COMMITTER_DATE"] = $"{date} +0000" }; + string order = string.Join(", ", members.Select(static member => $"#{member.Number}")); + + if (LocalAttributes() is string attributes) + { + return (null, attributes, -1); + } + + string train = main; + for (int i = 0; i < members.Count; i++) + { + TrainMember member = members[i]; + (int exit, string output) = MergeTree(train, member.Head); + string[] lines = output.Split('\n', StringSplitOptions.RemoveEmptyEntries); + if (exit == 1) + { + return (null, $"#{member.Number} conflicts with the train before it ({string.Join(", ", members.Take(i).Select(static m => $"#{m.Number}"))} on main) in {string.Join(", ", lines.Skip(1))}; a train's merge is never resolved by hand.", i); + } + + if (exit != 0 || lines.Length == 0) + { + throw new GitException($"git merge-tree {train[..12]} {member.Head[..12]} failed"); + } + + string message = $""" + Merge #{member.Number} into the train for main + + Member {i + 1} of {members.Count} of the train {order}, onto main at {main[..12]}, by + `git merge-tree` (eng/land.cs --train). + + Refs #{member.Number} + + {MergeTrailer} + {string.Join("\n", trailers)} + """.TrimEnd() + "\n"; + (int commitExit, string commit) = Shell.Try(dated, message, "git", "commit-tree", lines[0], "-p", train, "-p", member.Head, "-F", "-"); + if (commitExit != 0) + { + throw new GitException($"git commit-tree for #{member.Number} failed"); + } + + train = commit.Trim(); + } + + return (train, null, members.Count); + } + + // What to run without the member at `index` and those after it. + public static string Shorter(List<int> numbers, int index) => + index switch + { + < 0 => "", + 0 => "land: no shorter train: the first member is the one at fault.", + 1 => $"land: the train without #{numbers[1]}{(numbers.Count > 2 ? " and those after it" : "")} is #{numbers[0]} alone: dotnet run eng/land.cs -- {numbers[0]}", + _ => $"land: the train without #{numbers[index]}{(index < numbers.Count - 1 ? " and those after it" : "")}: dotnet run eng/land.cs -- --train {string.Join(" ", numbers.Take(index))}", + }; + + // A head is a train onto main when its first-parent line down to main is + // only `Landing-only: merge` commits of two parents, each one's tree the + // `git merge-tree` of its parents. The second parents, in order, or null + // and why not. eng/agent-review.cs holds its own copy of this rule. + public static (List<string>? Joined, string? Why) Verify(string main, string head) + { + if (LocalAttributes() is string attributes) + { + return (null, attributes); + } + + List<string> joined = []; + string at = head; + while (at != main) + { + (int exit, string parents) = Shell.Try(null, null, "git", "rev-list", "--parents", "-n", "1", at); + string[] shas = parents.Trim().Split(' '); + if (exit != 0) + { + return (null, $"{at[..12]} cannot be read"); + } + + if (shas.Length != 3) + { + return (null, $"{at[..12]} is not a merge of two parents, and main is not below it on its first-parent line"); + } + + if (!LandingOnlyMerge.IsMatch(Shell.Git("log", "-1", "--format=%B", at))) + { + return (null, $"{at[..12]} has no `{MergeTrailer}` trailer"); + } + + (int mergeExit, string merged) = MergeTree(shas[1], shas[2]); + if (mergeExit != 0 || merged.Split('\n')[0].Trim() != Shell.Git("rev-parse", $"{at}^{{tree}}")) + { + return (null, $"{at[..12]}'s tree is not `git merge-tree` of its parents under the pinned settings: it was resolved or edited by hand, or built by a different git. Build the train again"); + } + + joined.Insert(0, shas[2]); + at = shas[1]; + } + + return joined.Count < 2 ? (null, "a train has two or more members") : (joined, null); + } +} + +// git and other processes, with an environment and standard input, for the +// classes above (the top-level helpers cannot be called from a class). +static class Shell +{ + public static string Git(params string[] arguments) + { + (int exit, string output) = Try(null, null, "git", arguments); + return exit == 0 ? output.Trim() : throw new GitException($"git {string.Join(' ', arguments)} failed"); + } + + public static (int Exit, string Output) Try(Dictionary<string, string>? environment, string? input, string file, params string[] arguments) + { + ProcessStartInfo start = new() + { + FileName = file, + RedirectStandardInput = input is not null, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + }; + + foreach (string argument in arguments) + { + start.ArgumentList.Add(argument); + } + + foreach ((string key, string value) in environment ?? []) + { + start.Environment[key] = value; + } + + using Process process = Process.Start(start) ?? throw new GitException($"could not start {file}"); + if (input is not null) + { + process.StandardInput.Write(input); + process.StandardInput.Close(); + } + + Task<string> error = process.StandardError.ReadToEndAsync(); + string output = process.StandardOutput.ReadToEnd(); + process.WaitForExit(); + _ = error.Result; + return (process.ExitCode, output); + } +} + +// 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), + "train" => Train(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]); + } + + // A train case is built for real: a scratch bare repository holds main + // and each member as a commit on it, and the train is checked, built by + // `git merge-tree` and verified there. `approval` is "head", "other" (an + // approval naming another sha) or "none"; `green` false gives the member + // a failed required check; `edit` true replaces the last merge with one + // whose tree was changed by hand, which Verify must refuse. + static (bool, List<string>) Train(JsonElement root) + { + string scratch = Directory.CreateTempSubdirectory("land-train-").FullName; + Dictionary<string, string?> saved = []; + foreach ((string key, string value) in new Dictionary<string, string> + { + ["GIT_DIR"] = scratch, + ["GIT_AUTHOR_NAME"] = "Fixture", + ["GIT_AUTHOR_EMAIL"] = "fixture@example.org", + ["GIT_COMMITTER_NAME"] = "Fixture", + ["GIT_COMMITTER_EMAIL"] = "fixture@example.org", + }) + { + saved[key] = Environment.GetEnvironmentVariable(key); + Environment.SetEnvironmentVariable(key, value); + } + + try + { + Shell.Git("init", "--quiet", "--bare", scratch); + if (root.TryGetProperty("infoAttributes", out JsonElement local)) + { + Directory.CreateDirectory(Path.Combine(scratch, "info")); + File.WriteAllText(Path.Combine(scratch, "info", "attributes"), local.GetString()); + } + string main = Commit(scratch, null, root.GetProperty("main"), "chore: main\n\nRefs #1\n"); + List<TrainMember> members = []; + foreach (JsonElement member in root.GetProperty("members").EnumerateArray()) + { + int number = member.GetProperty("number").GetInt32(); + string message = member.TryGetProperty("message", out JsonElement written) ? written.GetString()! : $"feat: #{number}\n\nRefs #{number}\n"; + string head = Commit(scratch, main, member.GetProperty("files"), message); + List<string> approvals = member.GetProperty("approval").GetString() switch + { + "head" => [head[..12]], + "other" => [main[..12]], + _ => [], + }; + List<string> unmet = member.GetProperty("green").GetBoolean() ? [] : ["build (ubuntu-latest) (failure)"]; + string body = member.TryGetProperty("body", out JsonElement given) ? given.GetString()! : "Completes: none\n"; + List<(string, string)> commits = []; + foreach (string record in Shell.Git("log", "--format=%H%x00%B%x01", $"{main}..{head}").Split('\u0001', StringSplitOptions.RemoveEmptyEntries)) + { + string[] parts = record.TrimStart('\n').Split('\0', 2); + commits.Add((parts[0], parts[1])); + } + + members.Add(new TrainMember(number, head, approvals, unmet, body, commits, Trains.AddedDescriptors(main, head))); + } + + List<string> refusals = Trains.Check(members); + if (refusals.Count > 0) + { + return (false, refusals); + } + + List<int> numbers = [.. members.Select(static member => member.Number)]; + (string? train, string? conflict, int at) = Trains.Build(main, members, ["Signed-off-by: Fixture <fixture@example.org>"]); + if (train is null) + { + return (false, [conflict!, .. at >= 0 ? [Trains.Shorter(numbers, at)] : Array.Empty<string>()]); + } + + (string? again, _, _) = Trains.Build(main, members, ["Signed-off-by: Fixture <fixture@example.org>"]); + if (again != train) + { + return (false, [$"the same members on the same main built {train[..12]} and then {again?[..12]}"]); + } + + if (root.TryGetProperty("edit", out JsonElement edit) && edit.GetBoolean()) + { + string[] parents = Shell.Git("rev-list", "--parents", "-n", "1", train).Split(' '); + string blob = Shell.Try(null, "a line nobody reviewed\n", "git", "hash-object", "-w", "--stdin").Output.Trim(); + string index = Path.Combine(scratch, "edit-index"); + Dictionary<string, string> indexed = new() { ["GIT_INDEX_FILE"] = index }; + Shell.Try(indexed, null, "git", "read-tree", $"{train}^{{tree}}"); + Shell.Try(indexed, null, "git", "update-index", "--add", "--cacheinfo", $"100644,{blob},unreviewed.txt"); + string tree = Shell.Try(indexed, null, "git", "write-tree").Output.Trim(); + train = Shell.Try(null, Shell.Git("log", "-1", "--format=%B", train), "git", "commit-tree", tree, "-p", parents[1], "-p", parents[2], "-F", "-").Output.Trim(); + } + + (List<string>? joined, string? why) = Trains.Verify(main, train); + if (joined is null) + { + return (false, [$"the train {train[..12]} does not verify: {why}"]); + } + + if (!joined.SequenceEqual(members.Select(static member => member.Head))) + { + return (false, [$"the train {train[..12]} joins other heads than its members'"]); + } + + string merges = Shell.Git("log", "--first-parent", "--format=%B%x01", $"{main}..{train}"); + int marked = merges.Split('\u0001', StringSplitOptions.RemoveEmptyEntries).Count(static message => message.Contains(Trains.MergeTrailer, StringComparison.Ordinal)); + string subjects = Shell.Git("log", "--reverse", "--first-parent", "--format=%s", $"{main}..{train}").Replace("\n", "; ", StringComparison.Ordinal); + return (true, [$"the train {string.Join(", ", numbers.Select(static n => $"#{n}"))} is {train[..12]}, {marked} `{Trains.MergeTrailer}` merges ({subjects}), built twice the same, land/{Trains.Name(members)}"]); + } + finally + { + foreach ((string key, string? value) in saved) + { + Environment.SetEnvironmentVariable(key, value); + } + + Directory.Delete(scratch, recursive: true); + } + } + + // A commit of `files` (path to content) over `parent`'s tree, by plumbing. + static string Commit(string scratch, string? parent, JsonElement files, string message) + { + string index = Path.Combine(scratch, "fixture-index"); + File.Delete(index); + Dictionary<string, string> indexed = new() { ["GIT_INDEX_FILE"] = index }; + if (parent is not null) + { + Shell.Try(indexed, null, "git", "read-tree", $"{parent}^{{tree}}"); + } + + foreach (JsonProperty file in files.EnumerateObject()) + { + string blob = Shell.Try(null, file.Value.GetString()!, "git", "hash-object", "-w", "--stdin").Output.Trim(); + Shell.Try(indexed, null, "git", "update-index", "--add", "--cacheinfo", $"100644,{blob},{file.Name}"); + } + + string tree = Shell.Try(indexed, null, "git", "write-tree").Output.Trim(); + string[] arguments = parent is null ? ["commit-tree", tree, "-F", "-"] : ["commit-tree", tree, "-p", parent, "-F", "-"]; + return Shell.Try(null, message, "git", arguments).Output.Trim(); + } + + 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..c6726da8 --- /dev/null +++ b/tests/fixtures/land/README.md @@ -0,0 +1,44 @@ +# `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). A `train` case (#277) holds +`main`'s files and the `members`, each a `number`, the `files` its one commit +adds over main, its `approval` (`head`, `other` for one naming another sha, or +`none`), whether it is `green`, and optionally its `body` and commit +`message`; `edit` replaces the last merge with one whose tree was changed by +hand; `infoAttributes` writes the scratch repository's `info/attributes`. A train case is built for real, in a scratch bare repository: checked, +merged by `git merge-tree`, built twice to show it is the same, and verified as +`--main` and `agent review` verify it. 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 | +| `pass-train-two-members` | train | two approved, green members; two `Landing-only: merge` commits, the same head when built twice, `land/train-11-12` | +| `pass-train-descriptor-last` | train | the release descriptor's member last; `land/release-<version>` | +| `fail-train-one-member` | train | a train has two or more members; says to land it alone | +| `fail-train-approval-for-another-sha` | train | #12's approval names another sha | +| `fail-train-member-not-green` | train | a member with a failed required check | +| `fail-train-member-completes-unmet` | train | a member's `Completes: #5` with no trailer in its own commits | +| `fail-train-two-descriptors` | train | two members add release descriptors | +| `fail-train-descriptor-not-last` | train | the descriptor's member is not last | +| `fail-train-merge-conflicts` | train | #13 conflicts with #12 in `README.md`; prints the train without #13 | +| `fail-train-merge-edited-by-hand` | train | the last merge's tree is not `git merge-tree` of its parents | +| `pass-train-attributes-from-the-train` | train | `.gitattributes` in main's tree sets `merge=union`; the merge reads attributes from the train, never a working tree, so it is clean (ADR 0243 point 10) | +| `fail-train-info-attributes` | train | a repository-local `info/attributes`; refused, because no option switches it off | +| `fail-train-shorter-is-a-plain-landing` | train | #12 conflicts with #11; the shorter form has one member, so it prints `dotnet run eng/land.cs -- 11` | 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/fail-train-approval-for-another-sha.json b/tests/fixtures/land/fail-train-approval-for-another-sha.json new file mode 100644 index 00000000..f05d9bdc --- /dev/null +++ b/tests/fixtures/land/fail-train-approval-for-another-sha.json @@ -0,0 +1,13 @@ +{ + "step": "train", + "main": { "README.md": "Varve\n" }, + "members": [ + { "number": 11, "files": { "docs/a.md": "a\n" }, "approval": "head", "green": true }, + { "number": 12, "files": { "docs/b.md": "b\n" }, "approval": "other", "green": true } + ], + "says": [ + "#12's head", + "its approvals are for", + "A train lands only heads approved as they are." + ] +} diff --git a/tests/fixtures/land/fail-train-descriptor-not-last.json b/tests/fixtures/land/fail-train-descriptor-not-last.json new file mode 100644 index 00000000..df1d0804 --- /dev/null +++ b/tests/fixtures/land/fail-train-descriptor-not-last.json @@ -0,0 +1,11 @@ +{ + "step": "train", + "main": { "README.md": "Varve\n" }, + "members": [ + { "number": 11, "files": { "releases/v0.1.0-preview.99.yaml": "version: v0.1.0-preview.99\n" }, "approval": "head", "green": true }, + { "number": 12, "files": { "docs/b.md": "b\n" }, "approval": "head", "green": true } + ], + "says": [ + "#11 carries the release descriptor v0.1.0-preview.99, so it goes last in the train." + ] +} diff --git a/tests/fixtures/land/fail-train-info-attributes.json b/tests/fixtures/land/fail-train-info-attributes.json new file mode 100644 index 00000000..7030d7df --- /dev/null +++ b/tests/fixtures/land/fail-train-info-attributes.json @@ -0,0 +1,12 @@ +{ + "step": "train", + "main": { "README.md": "Varve\n" }, + "infoAttributes": "*.md merge=union\n", + "members": [ + { "number": 11, "files": { "docs/a.md": "a\n" }, "approval": "head", "green": true }, + { "number": 12, "files": { "docs/b.md": "b\n" }, "approval": "head", "green": true } + ], + "says": [ + "sets attributes, which change how a merge resolves; a train is built and judged without them (ADR 0243 point 10)." + ] +} diff --git a/tests/fixtures/land/fail-train-member-completes-unmet.json b/tests/fixtures/land/fail-train-member-completes-unmet.json new file mode 100644 index 00000000..1db1bc25 --- /dev/null +++ b/tests/fixtures/land/fail-train-member-completes-unmet.json @@ -0,0 +1,11 @@ +{ + "step": "train", + "main": { "README.md": "Varve\n" }, + "members": [ + { "number": 11, "files": { "docs/a.md": "a\n" }, "approval": "head", "green": true, "body": "Completes: #5\n" }, + { "number": 12, "files": { "docs/b.md": "b\n" }, "approval": "head", "green": true, "body": "Completes: #6\n", "message": "feat: b\n\nRefs #12\n\nCloses #6\n" } + ], + "says": [ + "#11: the pull request completes #5, and no pushed commit closes it." + ] +} diff --git a/tests/fixtures/land/fail-train-member-not-green.json b/tests/fixtures/land/fail-train-member-not-green.json new file mode 100644 index 00000000..140ad6a9 --- /dev/null +++ b/tests/fixtures/land/fail-train-member-not-green.json @@ -0,0 +1,11 @@ +{ + "step": "train", + "main": { "README.md": "Varve\n" }, + "members": [ + { "number": 11, "files": { "docs/a.md": "a\n" }, "approval": "head", "green": false }, + { "number": 12, "files": { "docs/b.md": "b\n" }, "approval": "head", "green": true } + ], + "says": [ + "is not green on its own: build (ubuntu-latest) (failure)" + ] +} diff --git a/tests/fixtures/land/fail-train-merge-conflicts.json b/tests/fixtures/land/fail-train-merge-conflicts.json new file mode 100644 index 00000000..5ea07130 --- /dev/null +++ b/tests/fixtures/land/fail-train-merge-conflicts.json @@ -0,0 +1,13 @@ +{ + "step": "train", + "main": { "README.md": "Varve\n" }, + "members": [ + { "number": 11, "files": { "docs/a.md": "a\n" }, "approval": "head", "green": true }, + { "number": 12, "files": { "README.md": "Varve, as #12 has it\n" }, "approval": "head", "green": true }, + { "number": 13, "files": { "README.md": "Varve, as #13 has it\n" }, "approval": "head", "green": true } + ], + "says": [ + "#13 conflicts with the train before it (#11, #12 on main) in README.md; a train's merge is never resolved by hand.", + "the train without #13: dotnet run eng/land.cs -- --train 11 12" + ] +} diff --git a/tests/fixtures/land/fail-train-merge-edited-by-hand.json b/tests/fixtures/land/fail-train-merge-edited-by-hand.json new file mode 100644 index 00000000..1b8f9d4e --- /dev/null +++ b/tests/fixtures/land/fail-train-merge-edited-by-hand.json @@ -0,0 +1,12 @@ +{ + "step": "train", + "main": { "README.md": "Varve\n" }, + "members": [ + { "number": 11, "files": { "docs/a.md": "a\n" }, "approval": "head", "green": true }, + { "number": 12, "files": { "docs/b.md": "b\n" }, "approval": "head", "green": true } + ], + "edit": true, + "says": [ + "'s tree is not `git merge-tree` of its parents under the pinned settings: it was resolved or edited by hand" + ] +} diff --git a/tests/fixtures/land/fail-train-one-member.json b/tests/fixtures/land/fail-train-one-member.json new file mode 100644 index 00000000..e1796f01 --- /dev/null +++ b/tests/fixtures/land/fail-train-one-member.json @@ -0,0 +1,10 @@ +{ + "step": "train", + "main": { "README.md": "Varve\n" }, + "members": [ + { "number": 11, "files": { "docs/a.md": "a\n" }, "approval": "head", "green": true } + ], + "says": [ + "a train has two or more members. Land #11 alone: dotnet run eng/land.cs -- 11" + ] +} diff --git a/tests/fixtures/land/fail-train-shorter-is-a-plain-landing.json b/tests/fixtures/land/fail-train-shorter-is-a-plain-landing.json new file mode 100644 index 00000000..69c7616f --- /dev/null +++ b/tests/fixtures/land/fail-train-shorter-is-a-plain-landing.json @@ -0,0 +1,12 @@ +{ + "step": "train", + "main": { "README.md": "Varve\n" }, + "members": [ + { "number": 11, "files": { "README.md": "Varve, as #11 has it\n" }, "approval": "head", "green": true }, + { "number": 12, "files": { "README.md": "Varve, as #12 has it\n" }, "approval": "head", "green": true } + ], + "says": [ + "#12 conflicts with the train before it (#11 on main) in README.md", + "the train without #12 is #11 alone: dotnet run eng/land.cs -- 11" + ] +} diff --git a/tests/fixtures/land/fail-train-two-descriptors.json b/tests/fixtures/land/fail-train-two-descriptors.json new file mode 100644 index 00000000..2d332482 --- /dev/null +++ b/tests/fixtures/land/fail-train-two-descriptors.json @@ -0,0 +1,11 @@ +{ + "step": "train", + "main": { "README.md": "Varve\n" }, + "members": [ + { "number": 11, "files": { "releases/v0.1.0-preview.98.yaml": "version: v0.1.0-preview.98\n" }, "approval": "head", "green": true }, + { "number": 12, "files": { "releases/v0.1.0-preview.99.yaml": "version: v0.1.0-preview.99\n" }, "approval": "head", "green": true } + ], + "says": [ + "the train carries 2 release descriptors (#11: v0.1.0-preview.98, #12: v0.1.0-preview.99); a train carries at most one." + ] +} 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/land/pass-train-attributes-from-the-train.json b/tests/fixtures/land/pass-train-attributes-from-the-train.json new file mode 100644 index 00000000..220d9df0 --- /dev/null +++ b/tests/fixtures/land/pass-train-attributes-from-the-train.json @@ -0,0 +1,11 @@ +{ + "step": "train", + "main": { ".gitattributes": "*.txt merge=union\n", "f.txt": "a\n" }, + "members": [ + { "number": 11, "files": { "f.txt": "a\nx\n" }, "approval": "head", "green": true }, + { "number": 12, "files": { "f.txt": "a\ny\n" }, "approval": "head", "green": true } + ], + "says": [ + "built twice the same, land/train-11-12" + ] +} diff --git a/tests/fixtures/land/pass-train-descriptor-last.json b/tests/fixtures/land/pass-train-descriptor-last.json new file mode 100644 index 00000000..1cd369e2 --- /dev/null +++ b/tests/fixtures/land/pass-train-descriptor-last.json @@ -0,0 +1,11 @@ +{ + "step": "train", + "main": { "README.md": "Varve\n" }, + "members": [ + { "number": 11, "files": { "docs/a.md": "a\n" }, "approval": "head", "green": true }, + { "number": 12, "files": { "releases/v0.1.0-preview.99.yaml": "version: v0.1.0-preview.99\n" }, "approval": "head", "green": true } + ], + "says": [ + "land/release-v0.1.0-preview.99" + ] +} diff --git a/tests/fixtures/land/pass-train-two-members.json b/tests/fixtures/land/pass-train-two-members.json new file mode 100644 index 00000000..876d3861 --- /dev/null +++ b/tests/fixtures/land/pass-train-two-members.json @@ -0,0 +1,28 @@ +{ + "step": "train", + "main": { + "README.md": "Varve\n" + }, + "members": [ + { + "number": 11, + "files": { + "docs/a.md": "a\n" + }, + "approval": "head", + "green": true + }, + { + "number": 12, + "files": { + "docs/b.md": "b\n" + }, + "approval": "head", + "green": true + } + ], + "says": [ + "the train #11, #12 is", + "2 `Landing-only: merge` merges (Merge #11 into the train for main; Merge #12 into the train for main), built twice the same, land/train-11-12" + ] +} 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