diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 7332e30f..86f6fd62 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -12,6 +12,16 @@ be able to decide from the body alone whether the work is sound, without reconstructing a finding from the diff. --> +Completes: none + + + ## What this changes, and why ## What was measured diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 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..f833e0d1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -95,8 +95,10 @@ severity on every project in `src/`. Every ADR is also a decision set in **Every milestone ends in a release, and a milestone PR carries its descriptor** (ADR 0102, `docs/releases.md`). The close-out: - adds `releases/.yaml` (`dotnet run eng/release.cs -- --draft - ` starts it), with a commit whose trailer line says `Closes - #`; + ` starts it, and `-- --sbom ` writes its digests and sets + its `date:` to that day, whenever they change), with a commit whose + trailer line says `Closes #`; `eng/land.cs` refuses one + dated more than three days before the landing (#248); - runs `dotnet run eng/changelog.cs -- --release `, because `CHANGELOG.md` is the projection of `releases/` and never edited by hand; - brings `README.md`'s **Status** section current: its opening line names the @@ -159,10 +161,51 @@ Conventional commits, one logical change each, and three things in each: exactly**, and the report. The model is the identifier the session's metadata reports. A tool that will not write it says so in the record, and the maintainer fills it in: a blank model is an incomplete record. **A commit message never -carries a model's identifier** (the string an API takes to select a model); a -tool's co-author trailer naming the product is not one (ADR 0033, amended -2026-10-02). Prose says "developed with AI assistance under human review" and -**names no product**. +carries a model's name** (a family word, alone or after the product, followed +by a version); a model's identifier (the string an API takes to select a +model) is allowed, and so is a co-author trailer naming only the product +(ADR 0241, superseding ADR 0033's amendment of 2026-10-02). Prose says +"developed with AI assistance under human review" and **names no product**. + +## Issues are the work log + +The issue, not the session's transcript or close-out, is where anyone looks to +see what is being worked on, what was decided and what is left. + +- **Starting.** Work starts from an issue. The session comments on it when it + starts (branch, and the prompt step it is working from) and posts its plan + there once the plan is agreed. Before opening an issue, search for an open + one that covers the work; a duplicate is closed pointing at the original. +- **While working.** Decisions taken, questions for the maintainer and blockers + go on the issue when they arise, not only in the close-out. The pull request + names its issues as bare #N in prose. +- **Found work is filed when it is found.** A defect, a follow-up or a deferred + part becomes its own issue at once, linked from the issue it came from. A + close-out, a TODO or a commit message is not a place to keep work. +- **Landing.** Before asking for approval, the session lists in the pull + request every issue the change touches, and its `Completes:` line names + those it resolves fully, or says `none`: + - each one it resolves fully gets its own `Closes #N` trailer, and + `eng/land.cs -- N` lists what the push will close and refuses a landing + whose `Completes:` line is missing or names an issue no trailer closes + (#209); + - each one it resolves in part gets a comment saying what landed and what is + left, and tracking issues (#15 and the milestone issues) get their boxes + ticked. + + "The session" here and below is whichever session lands. The maintainer's + delegate lands and checks the closings (ADR 0239); a cloud session posts its + head, lists the issues its change touches, and asks. +- **After landing.** The session checks that every issue it meant to close is + closed: `eng/land.cs -- N --main`, once `main` has moved, prints every issue + a pushed trailer names that is still open, with its commit, and exits + non-zero, as it does when GitHub cannot be read. It closes nothing itself. + If a trailer did not take, the session closes the issue by hand with a + comment naming the commit on `main`. +- **Stopping.** Work abandoned or superseded is closed as not planned, with a + comment saying why and what replaces it. +- The traceability record and the close-out link the issues; they do not + replace them. ## `Varve.Store` behaviour @@ -567,8 +610,8 @@ exists. - A native dependency, or any package that ships a native asset. - `System.Uri` where an IRI is meant. Use `Varve.Iri` (ADR 0004). - A commit with no issue reference or no DCO sign-off, or one whose message - carries a model's identifier; a long-lived branch; a trunk left - un-releasable. + carries a model's name (its identifier is allowed, ADR 0241); a long-lived + branch; a trunk left un-releasable. - Reflection, `Reflection.Emit` or `dynamic` in shipped code. - A `Common` / `Core` / `Utils` / `Abstractions` package, or an upward or same-layer reference. A `.cs` file without the MPL-2.0 notice (ADR 0031). diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 64599e4f..159a748a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -162,7 +162,9 @@ and tied to issues ([ADR 0033](docs/adr/0033-commit-traceability.md)). and the model, named exactly**, and the report it returned. The model is the identifier the session's metadata reports; where the tool will not write it, the record says so and the maintainer fills it in. A commit message never -carries a model's identifier (ADR 0033, amended 2026-10-02). +carries a model's name; a model's identifier is allowed, and so is a co-author +trailer naming only the product (ADR 0241, superseding ADR 0033's amendment of +2026-10-02). Prose in `README.md`, `CONTRIBUTING.md`, `GOVERNANCE.md` and the documentation describes the project as developed with AI assistance under human review and diff --git a/docs/adr/0033-commit-traceability.md b/docs/adr/0033-commit-traceability.md index 2ec3b9e1..319ffe52 100644 --- a/docs/adr/0033-commit-traceability.md +++ b/docs/adr/0033-commit-traceability.md @@ -13,6 +13,11 @@ the records. under *The wording rule splits*: what "named exactly" means for the model, and that a commit message never carries a model's identifier. +**Superseded in part by [0241](0241-model-names-refused-identifiers-allowed.md)** +(filed 2026-11-10): the amendment's ruling on the commit message is reversed. +A commit message never names a model, and a model's identifier is allowed. The +record still names the model exactly. + ## Context The Mind Over Machine stewardship standard asks for one thing under this diff --git a/docs/adr/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..442761bb 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -21,7 +21,7 @@ pull request at its approved head cuts it. format: 2 # mandatory; the format this file conforms to version: v0.1.0-preview.2 # v, prerelease allowed; equals the file's name title: The protocols and the server # one line; the tag message is " — " -date: 2026-10-20 # the preparation date; CHANGELOG.md's heading +date: 2026-10-20 # the day the sbom: digests were last written; CHANGELOG.md's heading commit: 0123…cdef # optional; a retro-cut only (§3). Omitted is normal basis: # mandatory; resolved at the commit under release - issue: 11 # each milestone issue it closes, at least one @@ -65,9 +65,27 @@ takes them for numbers. nuget.org cannot be replaced. 2. **The title carries no version.** The tag message and the GitHub Release's title are composed as `<version> — <title>`. -3. **`date` is the preparation date**, the day the descriptor was written. +3. **`date` is the preparation date**, ~~the day the descriptor was written~~. It is the heading of the version's section in `CHANGELOG.md`, which is a projection and must not depend on when the cut ran. + + **Amended 2026-11-10** (#248, under ADR 0102's amendment of that day): + the preparation date is **the day the descriptor's `sbom:` digests were + last written**, which is the day the artefacts being released were + prepared. The SBOMs are created at `date` (ADR 0224 point 2, unchanged), + so the two move together: + - `eng/release.cs -- --sbom <version>` writes the SBOMs, replaces the + `sbom:` block and sets `date:` to the current UTC day in one edit, and + regenerates `CHANGELOG.md` (rule 11); + - `eng/land.cs -- N` refuses a descriptor the pull request adds or + changes whose `date:` is more than three days before the landing day + (UTC), and prints that command. Three days is the release gate's + freshness window, and it spares a landing that crosses midnight or waits + out one red run; + - a descriptor with no `sbom:` block (format 1) keeps its date as it is. + + `CHANGELOG.md` still does not depend on when the cut ran. A descriptor + already cut keeps its date (rule 9): preview.18's stays 2026-10-27. 4. **`commit` is optional, and omitting it is normal.** A pull request cannot know the sha it will land as, so the default is "the commit that lands this file". When set, it must be a full sha and an ancestor of @@ -114,12 +132,14 @@ takes them for numbers. - **written, not typed.** After a restore, run: ```bash - dotnet run eng/sbom.cs -- --version <version without v> --created <date> \ - --out artifacts/sbom --syft <syft v1.54.0> + dotnet run eng/release.cs -- --sbom <version> --syft <syft v1.54.0> ``` - and copy `artifacts/sbom/sbom.txt`. The CLI binaries add `--binary - <rid>` each, once they are released (#129); + which runs `eng/sbom.cs` created at the current UTC day, writes its + `sbom.txt` into the block and that day into `date:` (rule 3, amended + 2026-11-10), and regenerates `CHANGELOG.md`. The CLI binaries add + `--binary <rid>` each, once they are released (#129). Run it again + whenever the digests change, a merge of `main` included; - **checked three times:** - `eng/release.cs` requires format 2 at a commit that carries `eng/sbom.cs`, with a line for every packable project and the image; diff --git a/docs/sessions/2026-11-issues-are-the-work-log.md b/docs/sessions/2026-11-issues-are-the-work-log.md new file mode 100644 index 00000000..177d0bd8 --- /dev/null +++ b/docs/sessions/2026-11-issues-are-the-work-log.md @@ -0,0 +1,69 @@ +# Session, 2026-11-10: issues are the work log + +Developed with AI assistance under human review. The full records, with the +prompts, are one per issue: +- [`2026-11-10-issue-257-issues-are-the-work-log.md`](../traceability/2026-11-10-issue-257-issues-are-the-work-log.md); +- [`2026-11-10-issue-209-closing-trailers-at-landing.md`](../traceability/2026-11-10-issue-209-closing-trailers-at-landing.md); +- [`2026-11-10-issue-248-the-descriptor-date-follows-the-digests.md`](../traceability/2026-11-10-issue-248-the-descriptor-date-follows-the-digests.md). + +A cloud session, not the maintainer's delegate (ADR 0239 point 1). It filed +its decisions unaccepted, posted each head, and asked; it approved, landed +and accepted nothing. + +## Pull requests + +| PR | Step | Closes | +|---|---|---| +| [#259](https://github.com/Hafeok/Varve/pull/259) | 1: `AGENTS.md`'s *Issues are the work log*; ADR 0241, a commit message never names a model and its identifier is allowed | #257 (`Refs #169`) | +| [#260](https://github.com/Hafeok/Varve/pull/260), stacked on #259 | 2: `land.cs` checks closing trailers before a push and after `main` moves; the `Completes:` line | #209 | +| [#261](https://github.com/Hafeok/Varve/pull/261), stacked on #260 | 3: `release.cs --sbom` writes the digests and the date together; `land.cs` refuses a date over three days old | #248 | + +They land in that order. + +## ADRs + +- **0241**, *A commit message never names a model; a model's identifier is + allowed*, filed unaccepted. It supersedes one ruling of 0033, + `ModelNamedInTheRecordNotTheCommit`, which moves to 0241's set under its + key. A reversal is a supersession, not an amendment (ADR 0068 point 2), so + the first plan, an amendment to 0033, was corrected on #257. +- **0102**, amended 2026-11-10, add-only, by #248: `date` is the day the + digests were last written. No ruling changes. ADR 0224 is untouched. + +## Issues + +- **Opened:** + - [#257](https://github.com/Hafeok/Varve/issues/257), the section, since no + issue covered it; + - [#258](https://github.com/Hafeok/Varve/issues/258), the decision report's + baseline is stale on `main`. +- **Closed on landing:** #257, #209 and #248, each by a trailer line. +- **Touched in part:** #169. `AGENTS.md` states its ruling once #259 lands; + its gate, rule page and port stay with it. The coordination is on #169. + +## Test counts + +| Suite | Before | After | +|---|---:|---:| +| `tests/fixtures/land/` (`land-fixtures`) | — | 12 cases | +| `tests/fixtures/release-sbom/` (`release-sbom-fixtures`) | — | 4 cases | +| `ci.yml`: a descriptor's digests rewritten on a later day | — | 1 step, Linux | + +No .NET test project changed. The Release build has zero warnings. + +## Questions + +- **Accept ADR 0241's key** `ModelNamedInTheRecordNotTheCommit`. Nothing in + code cites it, so #259 is not red on `CS0618`; it waits on acceptance all + the same. +- **Every open pull request needs a `Completes:` line** before `land.cs` lands + it, once #260 is on `main`. Today that is #253, #252, #247, #242, #238 and + #236. +- **The records leave the model for the maintainer.** The tool's configuration + keeps model identifiers out of every file it pushes, so ADR 0033's provision + applies. The identifier is in the session's metadata. + +## Deferred + +- [#258](https://github.com/Hafeok/Varve/issues/258): regenerate the decision + report's baseline in a change of its own. diff --git a/docs/traceability/2026-11-10-issue-209-closing-trailers-at-landing.md b/docs/traceability/2026-11-10-issue-209-closing-trailers-at-landing.md new file mode 100644 index 00000000..92271d30 --- /dev/null +++ b/docs/traceability/2026-11-10-issue-209-closing-trailers-at-landing.md @@ -0,0 +1,59 @@ +# Closing trailers checked at landing + +> **Recorded contemporaneously**, by the session that did the work, under +> [ADR 0033](../adr/0033-commit-traceability.md). The prompt is verbatim. The +> transcript itself is held by the maintainer. + +| | | +|---|---| +| **Issues** | [#209](https://github.com/Hafeok/Varve/issues/209); [#257](https://github.com/Hafeok/Varve/issues/257) and [#108](https://github.com/Hafeok/Varve/issues/108) referenced | +| **Date** | 2026-11-10 | +| **Tool** | Claude Code 2.1.322, a cloud session started from the desktop app. Not the maintainer's delegate (ADR 0239 point 1) | +| **Model** | *Left for the maintainer.* The session's metadata reports the model, but this tool's configuration keeps model identifiers out of every file it pushes, so the maintainer fills it in from the session's metadata (ADR 0033, ADR 0241 point 3). | +| **Session identifier** | `session_01NbWSYQ7rmZCcSgsvBFhZMm` | +| **Branch** | `claude/dazzling-darwin-8irixp-step2`, on step 1's branch at c2e965e | +| **ADRs** | none decided; [0088](../adr/0088-only-checked-commits-reach-main.md) and [0102](../adr/0102-a-release-is-a-descriptor.md) applied | + +## The prompt + +> ## Step 2 — #209: closing trailers checked at landing +> +> Implement #209 as written: the `Completes:` line in the PR template, `land.cs -- N` listing the issues the push will close and refusing a declared issue with no closing trailer, a missing `Completes:` line refused, the descriptor's own trailer counting, and the four fixtures under `tests/fixtures/land/` run from `eng/ci.cs`. +> +> One addition, which goes on #209 as a comment before you build it: **after `land.cs -- N --main` has fast-forwarded `main`, it checks that every issue named in a closing trailer of the pushed commits is closed.** For any still open, it prints the issue and the commit, and exits non-zero. It never closes anything itself; closing by hand stays a human or session act with a comment. A fixture covers it: a pushed trailer whose issue is still open is reported. If the GitHub state can't be read (no token, rate limit), it says so and exits non-zero, rather than reporting success. +> +> Document the `Completes:` line and the after-landing check in the step 1 section of `AGENTS.md` (the **Landing** and **After landing** bullets), so the rule and the script describe the same thing. +> +> Commit trailer: `Closes #209`. + +The maintainer agreed the plan posted on #209 ("All three plans are agreed. Go ahead."). + +## The report + +- **The addition went on #209 as a comment** before the code, with the plan. +- **`eng/land.cs`:** + - At both steps it lists every issue a closing trailer line in + `main..head` closes, with its title. A trailer is recognised as + `eng/issue-refs.cs` recognises one, and the subject line is skipped. + - It reads the pull request's `Completes:` line, ignoring HTML comments, + so the template's guidance doesn't count. It refuses a missing, + duplicated or malformed line, and a declared issue no trailer closes. + - When the pull request's body can't be read, it refuses (exit 2) rather + than skipping the check. + - A `--commit` landing has no pull request, so it only lists. + - After `--main` it asks GitHub for each issue's state, again for up to a + minute while one is open, since GitHub closes issues a little after the + push. An issue still open is printed with its commit (exit 1), and + GitHub unreadable is exit 2. + - The decisions are pure functions over plain values, so `--fixtures` + judges them with no git or GitHub. +- **Nine fixtures:** #209's four, the descriptor's trailer, a malformed + line, and three after landing (all closed, one open, unreadable). The + `land-fixtures` job runs them. The failure path was proven: a `pass-` case + copied as `fail-` gave "10 cases, 1 judged wrong", exit 1. +- **A real dry run**, `land.cs -- 253 --print`, listed #251 with its title + and refused #253 for having no `Completes:` line. **Every pull request + open when this lands needs the line added before it lands.** The template + starts new ones with `Completes: none`. +- **`AGENTS.md`'s Landing and After landing bullets** describe the line, the + refusal and the check after landing. diff --git a/docs/traceability/2026-11-10-issue-248-the-descriptor-date-follows-the-digests.md b/docs/traceability/2026-11-10-issue-248-the-descriptor-date-follows-the-digests.md new file mode 100644 index 00000000..0cf594d9 --- /dev/null +++ b/docs/traceability/2026-11-10-issue-248-the-descriptor-date-follows-the-digests.md @@ -0,0 +1,74 @@ +# The descriptor's date follows its digests + +> **Recorded contemporaneously**, by the session that did the work, under +> [ADR 0033](../adr/0033-commit-traceability.md). The prompts are verbatim. The +> transcript itself is held by the maintainer. + +| | | +|---|---| +| **Issues** | [#248](https://github.com/Hafeok/Varve/issues/248); [#209](https://github.com/Hafeok/Varve/issues/209) referenced | +| **Date** | 2026-11-10 | +| **Tool** | Claude Code 2.1.322, a cloud session started from the desktop app. Not the maintainer's delegate (ADR 0239 point 1) | +| **Model** | *Left for the maintainer.* The session's metadata reports the model, but this tool's configuration keeps model identifiers out of every file it pushes, so the maintainer fills it in from the session's metadata (ADR 0033, ADR 0241 point 3). | +| **Session identifier** | `session_01NbWSYQ7rmZCcSgsvBFhZMm` | +| **Branch** | `claude/dazzling-darwin-8irixp-step3`, on step 2's branch at 4051685 | +| **ADRs** | [0102](../adr/0102-a-release-is-a-descriptor.md) (amended 2026-11-10), [0224](../adr/0224-supply-chain-transparency.md) (untouched) | + +## The prompts + +### The maintainer, in the session + +> Add a step 3: #248, the descriptor date set at landing, and where the SBOM creation timestamp comes from. Report your plan for all three steps before writing code. + +> You were right about #248: its premise was wrong and the issue is corrected. The ruling is on the issue now: one field, release.cs sets date: whenever it rewrites the sbom: block, land.cs refuses a date more than three days old, a dated amendment to docs/releases.md rule 3, and ADR 0224 untouched. No sbom-created: field. + +### The session prompt, step 3, as attached + +> ## Step 3 — #248: the descriptor date follows the digests +> +> Implement #248 as corrected on 2026-11-10. Its first version had a wrong premise (it said the SBOM creation time departs from ADR 0224; it doesn't, D3 stands). The plan on the issue is the ruling: +> - one field, no `sbom-created:`, and ADR 0224 untouched; +> - `eng/release.cs` sets `date:` to the current UTC day whenever it rewrites the `sbom:` block, and regenerates `CHANGELOG.md`; +> - `eng/land.cs -- N` refuses a descriptor dated more than three days before the landing day, and prints the command that rewrites the digests; +> - a dated amendment to `docs/releases.md` rule 3 (from ADR 0102); +> - the gates listed on the issue. +> +> Commit trailer: `Closes #248`. + +## The report + +- **Where the SBOM creation time comes from**, found before the ruling: + `publish.yml` and `ci.yml` pass the descriptor's `date` as `--created`, + which is what ADR 0224 point 2 (D3) decides. The first version of #248 was + wrong about that, and the maintainer corrected it. +- **Nothing rewrote the `sbom:` block before**: the digests were pasted from + `sbom.txt` by hand. So the rewrite is a new command, + `eng/release.cs -- --sbom <version>`. It runs `eng/sbom.cs` created at the + current UTC day, replaces the block and sets `date:` in one edit, and + regenerates `CHANGELOG.md`. + - `--from <dir>` takes SBOMs already written, and refuses them unless every + one was created on that day. + - `--draft` points at the command. +- **`eng/land.cs`** refuses, at both steps, a descriptor the head adds or + changes whose `date:` is more than three days before the landing day + (UTC), or that has none, and prints the `--sbom` command. +- **Two changes to the posted plan, recorded on #248:** + - The branch is stacked on #209's, because both add `land.cs` fixtures and + one runner serves both. + - ADR 0102 gets a dated amendment beside `docs/releases.md` rule 3, because + rule 3 restates 0102's line on `date`. It adds detail and changes no + ruling (ADR 0068 point 2). ADR 0224 is untouched. +- **Gates:** + - `tests/fixtures/release-sbom/`: four cases, 0 judged wrong. The block and + the date move together; a draft's placeholder is replaced; SBOMs created + on another day are refused; a format-1 descriptor is refused. + - `tests/fixtures/land/`: three date cases joined. Four days is refused, + three days passes, and no date is refused. That makes 12 cases, 0 judged + wrong. + - A `ci.yml` step on Linux rewrites the newest format-2 descriptor, renamed + and backdated, in a scratch directory. It requires `date:` to be today, + every SBOM created at today 00:00Z, and `eng/sbom.cs --descriptor` to + match. Run locally: 29 SBOMs created at 2026-11-10T00:00:00Z, and "sbom + lines match the 29 generated SBOM(s)". Checked against 2026-11-09 + instead, every line differed and the exit was 1. +- **Preview.18 keeps 2026-10-27**: it is cut, and nothing touches it. diff --git a/docs/traceability/2026-11-10-issue-257-issues-are-the-work-log.md b/docs/traceability/2026-11-10-issue-257-issues-are-the-work-log.md new file mode 100644 index 00000000..7db0e760 --- /dev/null +++ b/docs/traceability/2026-11-10-issue-257-issues-are-the-work-log.md @@ -0,0 +1,75 @@ +# Issues are the work log, and model names refused in commit messages + +> **Recorded contemporaneously**, by the session that did the work, under +> [ADR 0033](../adr/0033-commit-traceability.md). The prompts are verbatim. The +> transcript itself is held by the maintainer. + +| | | +|---|---| +| **Issues** | [#257](https://github.com/Hafeok/Varve/issues/257); [#169](https://github.com/Hafeok/Varve/issues/169) referenced | +| **Date** | 2026-11-10 | +| **Tool** | Claude Code 2.1.322, a cloud session started from the desktop app. Not the maintainer's delegate (ADR 0239 point 1) | +| **Model** | *Left for the maintainer.* The session's metadata reports the model, but this tool's configuration keeps model identifiers out of every file it pushes, so the maintainer fills it in from the session's metadata (ADR 0033, ADR 0241 point 3). | +| **Session identifier** | `session_01NbWSYQ7rmZCcSgsvBFhZMm` | +| **Branch** | `claude/dazzling-darwin-8irixp`, from `main` at 81fba3d | +| **ADRs** | [0241](../adr/0241-model-names-refused-identifiers-allowed.md) (filed unaccepted), [0033](../adr/0033-commit-traceability.md) (one ruling superseded) | + +## The prompts + +### The session prompt, step 1, as the maintainer attached it + +> ## Step 1 — AGENTS.md: "Issues are the work log" +> +> No issue covers this yet. Search for an open one first; if none exists, open one titled "AGENTS.md: issues are the work log", with the text below as its proposal. That issue is what this step's commit closes. +> +> Add this section to `AGENTS.md` directly after **Every commit**, word for word unless something in it contradicts an accepted ADR, in which case say so on the issue before changing it: +> +> *(the section, as it now stands in `AGENTS.md`, apart from the landing note below)* +> +> Also correct the model-identifier rule. The maintainer's ruling of 2026-10-27, which #169's gate implements, is: **a commit message never carries a model's name; a model's identifier is allowed.** `AGENTS.md` still says the reverse in **Every commit** ("A commit message never carries a model's identifier …") and in **Never** ("… or one whose message carries a model's identifier"). Rewrite both lines to the ruling. The traceability record still names the tool and the model exactly; that line doesn't change. If a PR for #169 is open and touches the same lines, coordinate on #169 instead of conflicting with it, and say which you did. +> +> If `CLAUDE.md` or any other agent instructions file repeats either rule, keep it consistent (CLAUDE.md only points at AGENTS.md today, per ADR 0033). +> +> Commit trailers: `Closes #<the new issue>` and `Refs #169`. +> +> ## Added 2026-11-10 +> +> **AGENTS.md has changed since this prompt was written.** ADR 0239 (the maintainer's delegate) amended the `accepted-by` rule: only the maintainer, or his delegate for a decision he has read, writes `accepted-by`. A cloud session is not the delegate. Fit the new section around that text. In its **Landing** and **After landing** bullets, "the session" means whichever session lands: the delegate lands and checks the closings; a cloud session posts its head, lists the issues its change touches, and asks. + +### The maintainer, in the session + +> Read AGENTS.md, then the session prompt in varve-claude-code-issue-log.md. Note that ADR 0239 has since changed AGENTS.md's accepted-by rule, so fit the new section around it. Add a step 3: #248, the descriptor date set at landing, and where the SBOM creation timestamp comes from. Report your plan for all three steps before writing code. + +> You're right that you are not the delegate: file decisions unaccepted, post your heads, and ask. Post your plan for all three steps on their issues before writing code. + +> All three plans are agreed. Go ahead. […] Step 1's amendment to ADR 0033 and its replacing key stay unaccepted, and the pull request red, until the maintainer or his delegate accepts them. Post each head when its checks other than that are green. + +## The report + +- **No issue covered the work**, so the session opened #257 with the section + as its proposal, commented when it started, and posted the plan there and + on #169. +- **The model rule contradicts an accepted decision.** + `CommitTraceability.ModelNamedInTheRecordNotTheCommit` (ADR 0033's amendment + of 2026-10-02, accepted that day) refused a model's API identifier in a + commit message. The ruling of 2026-10-27 reverses it. ADR 0068 point 2 makes + a change of meaning a supersession, not an amendment, so the plan posted + first (an amendment and a new key) was corrected on #257: **ADR 0241** + supersedes that one ruling, and the key moves to 0241's set unchanged in + name, with the new statement and no `accepted-by` (ADR 0068 point 4). + 0033's Status line names the supersession. 0240 is taken on another branch. +- **What changed:** `AGENTS.md` gains *Issues are the work log* after + **Every commit**, word for word apart from one added paragraph under + **Landing**: "the session" is whichever session lands, as the prompt's note + asks. **Every commit** and **Never** state the ruling. `CONTRIBUTING.md` + repeated the old rule and is rewritten to match. `CLAUDE.md` only points + at `AGENTS.md`, and no other instructions file states either rule. +- **Measured:** `eng/decision-sets.cs` passes with 1,468 decisions, 1,467 + accepted; the one unaccepted is this ADR's. The Release build has zero + warnings. Nothing in code cites the key, so its being unaccepted produces no + `CS0618`; whether the pull request is red on it is for the gates to say. +- **Found:** `eng/decision-report/baseline.md` is stale on `main`. + Regenerating it rewrites some 250 lines this change did not cause. It does + not gate, so it is left for a change of its own: #258. +- **#169 is coordinated, not conflicted with:** no open pull request for it + touched these lines. Its gate, rule page and port remain its own. diff --git a/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/eng/ci.cs b/eng/ci.cs index fcdfd6a4..f0e7a77e 100644 --- a/eng/ci.cs +++ b/eng/ci.cs @@ -119,6 +119,15 @@ ("formats-fixtures", "the format gate passes and refuses its fixtures as their names say", () => Run("dotnet", ["run", "eng/formats.cs", "--", "--fixtures", "tests/fixtures/formats"])), + // release.cs --sbom's rewrite: the block and the date in one edit, and + // SBOMs created on another day refused (#248, tests/fixtures/release-sbom/README.md). + ("release-sbom-fixtures", "release.cs --sbom rewrites a descriptor's digests and date together, and refuses SBOMs created on another day", + () => Run("dotnet", ["run", "eng/release.cs", "--", "--sbom-fixtures", "tests/fixtures/release-sbom"])), + // land.cs's closing-trailer checks, before a push and after main moved, + // on every run (#209, tests/fixtures/land/README.md). + ("land-fixtures", "land.cs refuses a missing or unmet Completes: line and a descriptor dated over three days before landing, and reports an issue left open after landing, as its fixtures say", + () => Run("dotnet", ["run", "eng/land.cs", "--", "--fixtures", "tests/fixtures/land"])), + // The release gate's refusal of stale or red evidence from main, preview and not, proven on // every run (ADR 0215, tests/fixtures/release-freshness/README.md). ("release-freshness-fixtures", "the release gate's freshness check, preview and not, passes and refuses its fixtures as their names say", diff --git a/eng/land.cs b/eng/land.cs index 9523c8bc..597aec78 100644 --- a/eng/land.cs +++ b/eng/land.cs @@ -10,6 +10,7 @@ // // --name <name> the land/ branch's name, in place of the one worked out // --print print the git commands, run none +// --fixtures <dir> judge the closing-trailer fixtures (tests/fixtures/land/) // // ADR 0088: main accepts only a commit whose checks have passed, and a human // gets one there by pushing it to land/<name>, letting CI and `agent review` @@ -35,7 +36,26 @@ // `--main` refuses unless land/<name> on origin is the same commit, so main // moves only to the commit that was checked there. // -// Exit codes: 0 done, 1 refused, 2 could not run. +// Closing trailers (#209). At both steps it lists every issue a `Closes`, +// `Fixes` or `Resolves #N` trailer line in the pushed commits will close, +// with its title. A pull request's body must carry one line `Completes: #N, +// ...` or `Completes: none` (the pull request template); the landing is +// refused when that line is missing, malformed, or names an issue no trailer +// closes. A release descriptor's own trailer is a commit's trailer like any +// other, so it counts. A `--commit` landing has no pull request, so it is only +// listed. After `--main` has moved main, every issue a pushed trailer names +// must be closed: one still open is printed with its commit and the exit is +// non-zero. It never closes anything itself, and when GitHub cannot be read +// it says so and exits non-zero rather than reporting success. +// +// A descriptor's date (#248). A release descriptor the head adds or changes is +// refused, at both steps, when its `date:` is more than three days before the +// landing day (UTC): the date is the day its SBOM digests were last written +// (docs/releases.md rule 3, amended 2026-11-10), and land.cs prints the +// command that writes them again. +// +// Exit codes: 0 done, 1 refused (or an issue left open after landing), 2 +// could not run (GitHub unreadable included). // // See docs/adr/0088-only-checked-commits-reach-main.md. @@ -43,6 +63,7 @@ #:property NoWarn=$(NoWarn);CA2266 using System.Diagnostics; +using System.Text.Json; using System.Text.RegularExpressions; const string Remote = "origin"; @@ -52,6 +73,7 @@ string? name = null; bool toMain = false; bool print = false; +string? fixtures = null; for (int i = 0; i < args.Length; i++) { @@ -69,6 +91,9 @@ case "--name" when i + 1 < args.Length: name = args[++i]; break; + case "--fixtures" when i + 1 < args.Length: + fixtures = args[++i]; + break; case "--help" or "-h": Usage(); return 0; @@ -85,6 +110,11 @@ } } +if (fixtures is not null) +{ + return Fixtures.Run(fixtures); +} + if (pullRequest is null == commit is null) { Console.Error.WriteLine("land: give a pull request number or --commit <rev>, one of them"); @@ -125,6 +155,49 @@ return 1; } + DateOnly landingDay = DateOnly.FromDateTime(DateTime.UtcNow); + List<string> stale = []; + foreach ((string version, string date) in PendingDescriptors(main, head)) + { + if (Dates.Check(version, date, landingDay) is string refusal) + { + stale.Add(refusal); + } + } + + if (stale.Count > 0) + { + foreach (string refusal in stale) + { + Console.Error.WriteLine($"land: {refusal}"); + } + + return 1; + } + + List<Trailer> closing = Closings.Trailers(PushedCommits(main, head)); + ListClosings(closing); + if (pullRequest is int declared) + { + string? body = PullRequestBody(declared); + if (body is null) + { + Console.Error.WriteLine($"land: could not read #{declared}'s body with the GitHub CLI, so its Completes: line cannot be checked; refusing rather than skipping it."); + return 2; + } + + List<string> refusals = Closings.CheckCompletes(body, closing); + if (refusals.Count > 0) + { + foreach (string refusal in refusals) + { + Console.Error.WriteLine($"land: {refusal}"); + } + + return 1; + } + } + string? landed = RemoteHead(land); if (!toMain) @@ -172,7 +245,13 @@ Console.WriteLine(); Console.WriteLine($"land: main is at {head[..12]}.{(name.StartsWith("release-", StringComparison.Ordinal) ? " The release workflow now cuts the release it carries." : "")}"); - return 0; + (int afterExit, List<string> afterSaid) = Closings.CheckClosed(closing, IssueStates(closing)); + foreach (string line in afterSaid) + { + (afterExit == 0 ? Console.Out : Console.Error).WriteLine($"land: {line}"); + } + + return afterExit; } catch (GitException exception) { @@ -180,6 +259,95 @@ return 2; } +// The commits main..head, each its sha and full message. +static List<(string Sha, string Message)> PushedCommits(string main, string head) +{ + List<(string, string)> commits = []; + foreach (string record in Git("log", "--format=%H%x00%B%x01", $"{main}..{head}").Split('\u0001', StringSplitOptions.RemoveEmptyEntries)) + { + string trimmed = record.TrimStart('\n', '\r'); + int separator = trimmed.IndexOf('\0', StringComparison.Ordinal); + if (separator > 0) + { + commits.Add((trimmed[..separator], trimmed[(separator + 1)..])); + } + } + + return commits; +} + +static void ListClosings(List<Trailer> closing) +{ + if (closing.Count == 0) + { + Console.WriteLine("land: the push closes no issue."); + return; + } + + Console.WriteLine("land: the push closes:"); + foreach (IGrouping<int, Trailer> issue in closing.GroupBy(static trailer => trailer.Issue).OrderBy(static group => group.Key)) + { + (int exit, string title) = Try("gh", "api", $"repos/{{owner}}/{{repo}}/issues/{issue.Key}", "--jq", ".title"); + string titled = exit == 0 ? $" {title.Trim()}" : ""; + Console.WriteLine($" #{issue.Key}{titled} ({string.Join(", ", issue.Select(static trailer => trailer.Sha[..12]))})"); + } +} + +static string? PullRequestBody(int number) +{ + (int exit, string output) = Try("gh", "api", $"repos/{{owner}}/{{repo}}/pulls/{number}", "--jq", ".body // \"\""); + return exit == 0 ? output : null; +} + +// Each issue's state, "open" or "closed", or null when GitHub cannot be read. +// GitHub closes an issue a little after the push, so an open one is asked +// again for up to a minute before it counts. +static Dictionary<int, string>? IssueStates(List<Trailer> closing) +{ + Dictionary<int, string> states = []; + int[] issues = [.. closing.Select(static trailer => trailer.Issue).Distinct()]; + for (int attempt = 0; ; attempt++) + { + foreach (int issue in issues) + { + (int exit, string output) = Try("gh", "api", $"repos/{{owner}}/{{repo}}/issues/{issue}", "--jq", ".state"); + if (exit != 0) + { + return null; + } + + states[issue] = output.Trim(); + } + + if (attempt >= 6 || states.Values.All(static state => state == "closed")) + { + return states; + } + + Thread.Sleep(TimeSpan.FromSeconds(10)); + } +} + +// Each release descriptor the head adds or changes over main: its version and +// its date: as written at the head. +static List<(string Version, string Date)> PendingDescriptors(string main, string head) +{ + List<(string, string)> descriptors = []; + foreach (string path in Git("diff", "--name-only", "--diff-filter=AM", main, head, "--", "releases/").Split('\n', StringSplitOptions.RemoveEmptyEntries)) + { + Match name = Regex.Match(path, @"^releases/(v[^/]+)\.yaml$"); + if (!name.Success) + { + continue; + } + + Match date = Regex.Match(Git("show", $"{head}:{path}"), @"^date:[ \t]*(\S*)", RegexOptions.Multiline); + descriptors.Add((name.Groups[1].Value, date.Success ? date.Groups[1].Value : "")); + } + + return descriptors; +} + // release-<version>, when the head adds a descriptor that main does not have. static string? ReleaseName(string main, string head) { @@ -288,6 +456,197 @@ dotnet run eng/land.cs -- <pr> push the pull request's head to land options: --name <name> the land/ branch's name, in place of the one worked out --print print the git commands, run none + --fixtures <dir> judge the closing-trailer fixtures """); sealed class GitException(string message) : Exception(message); + +sealed record Trailer(int Issue, string Sha); + +// The decisions, over plain values, so that the fixtures can judge them +// without git or GitHub. +static class Closings +{ + // eng/issue-refs.cs's closingTrailer: a closing keyword and a number, + // alone on a line. The subject line is not a trailer. + static readonly Regex ClosingTrailer = new(@"^\s*(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?):?\s+(?:(?<repository>[\w.-]+/[\w.-]+))?#(?<issue>\d+)\s*$", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); + + static readonly Regex CompletesLine = new(@"^[ \t]*Completes:[ \t]*(?<value>.*?)[ \t]*$", RegexOptions.Multiline | RegexOptions.CultureInvariant); + + public static List<Trailer> Trailers(IEnumerable<(string Sha, string Message)> commits) + { + List<Trailer> trailers = []; + foreach ((string sha, string message) in commits) + { + foreach (string line in message.Split('\n').Skip(1)) + { + Match match = ClosingTrailer.Match(line.TrimEnd('\r')); + if (match.Success && (!match.Groups["repository"].Success || match.Groups["repository"].Value.Equals("Hafeok/Varve", StringComparison.OrdinalIgnoreCase))) + { + trailers.Add(new Trailer(int.Parse(match.Groups["issue"].Value, System.Globalization.CultureInfo.InvariantCulture), sha)); + } + } + } + + return trailers; + } + + // What a pull request's Completes: line refuses. Empty means it may land. + public static List<string> CheckCompletes(string body, List<Trailer> closing) + { + // Text inside HTML comments is the template's guidance, not the line. + string visible = Regex.Replace(body, "<!--.*?-->", "", RegexOptions.Singleline); + MatchCollection lines = CompletesLine.Matches(visible); + if (lines.Count == 0) + { + return ["the pull request has no `Completes:` line. Add `Completes: #N, ...` naming every issue it completes, or `Completes: none` (#209)."]; + } + + if (lines.Count > 1) + { + return ["the pull request has more than one `Completes:` line; keep one."]; + } + + string value = lines[0].Groups["value"].Value; + if (value.Equals("none", StringComparison.OrdinalIgnoreCase)) + { + return []; + } + + HashSet<int> declared = []; + foreach (string item in value.Split(',', StringSplitOptions.TrimEntries)) + { + Match number = Regex.Match(item, @"^#(\d+)$"); + if (!number.Success) + { + return [$"the `Completes:` line reads '{value}'; it is `Completes: none` or issues as #N separated by commas."]; + } + + declared.Add(int.Parse(number.Groups[1].Value, System.Globalization.CultureInfo.InvariantCulture)); + } + + HashSet<int> closed = [.. closing.Select(static trailer => trailer.Issue)]; + return [.. declared.Where(issue => !closed.Contains(issue)).Order().Select(static issue => + $"the pull request completes #{issue}, and no pushed commit closes it. Add a `Closes #{issue}` trailer line to the commit that completes it.")]; + } + + // After main moved: 0 when every closed issue is closed, 1 when one is + // still open, 2 when the states could not be read. + public static (int Exit, List<string> Said) CheckClosed(List<Trailer> closing, Dictionary<int, string>? states) + { + if (closing.Count == 0) + { + return (0, []); + } + + if (states is null) + { + return (2, ["could not read the closed issues' state from GitHub (no token, or a rate limit), so the closings are not confirmed. Check them by hand."]); + } + + List<string> said = []; + foreach (IGrouping<int, Trailer> issue in closing.GroupBy(static trailer => trailer.Issue).OrderBy(static group => group.Key)) + { + if (!states.TryGetValue(issue.Key, out string? state) || state != "closed") + { + said.Add($"#{issue.Key} is still open, though {string.Join(", ", issue.Select(static trailer => trailer.Sha[..12]))} closes it. Close it by hand with a comment naming the commit on main."); + } + } + + return said.Count == 0 + ? (0, [$"every issue the push closes is closed: {string.Join(", ", closing.Select(static trailer => $"#{trailer.Issue}").Distinct())}."]) + : (1, said); + } +} + +// A descriptor's date against the landing day (#248): null when it may land. +static class Dates +{ + public const int Window = 3; + + public static string? Check(string version, string date, DateOnly landingDay) + { + string rewrite = $"dotnet run eng/release.cs -- --sbom {version} --syft <syft v1.54.0> --binary <rid>..."; + if (!DateOnly.TryParseExact(date, "yyyy-MM-dd", System.Globalization.CultureInfo.InvariantCulture, System.Globalization.DateTimeStyles.None, out DateOnly day)) + { + return $"releases/{version}.yaml has no date: as yyyy-mm-dd ('{date}'). Write its digests and date again:\n {rewrite}"; + } + + int age = landingDay.DayNumber - day.DayNumber; + return age <= Window + ? null + : $"releases/{version}.yaml is dated {date}, {age} days before the landing day {landingDay:yyyy-MM-dd}; at most {Window} are allowed, because the date is the day its SBOM digests were written (docs/releases.md rule 3). Write them again, which sets the date, then commit, push and ask for approval of the new head:\n {rewrite}"; + } +} + +// tests/fixtures/land/: each case a JSON file. `step` is "push" (the +// Completes: check before a push) or "landed" (the check after main moved). +// A "date" case holds a descriptor's `version`, `date` and the `landing` day, +// and no commits. `commits` are the pushed commits ({sha, message}); `body` +// the pull request's body; `states` the issues' states, null when GitHub +// cannot be read. A +// `pass-` case must pass and a `fail-` case be refused, and `says` lists text +// the output must contain. +static class Fixtures +{ + public static int Run(string directory) + { + string[] cases = [.. Directory.GetFiles(directory, "*.json").Order(StringComparer.Ordinal)]; + int wrong = 0; + foreach (string file in cases) + { + using JsonDocument fixture = JsonDocument.Parse(File.ReadAllText(file)); + JsonElement root = fixture.RootElement; + List<Trailer> closing = root.TryGetProperty("commits", out JsonElement commits) + ? Closings.Trailers(commits.EnumerateArray().Select(static commit => (commit.GetProperty("sha").GetString()!, commit.GetProperty("message").GetString()!))) + : []; + string step = root.GetProperty("step").GetString()!; + (bool passes, List<string> said) = step switch + { + "push" => Push(root, closing), + "landed" => Landed(root, closing), + "date" => Date(root), + _ => throw new InvalidOperationException($"{file}: unknown step '{step}'"), + }; + + string output = string.Join("\n", said); + string[] missing = root.TryGetProperty("says", out JsonElement says) + ? [.. says.EnumerateArray().Select(static line => line.GetString()!).Where(line => !output.Contains(line, StringComparison.Ordinal))] + : []; + bool expected = Path.GetFileName(file).StartsWith("pass-", StringComparison.Ordinal); + bool right = passes == expected && missing.Length == 0; + wrong += right ? 0 : 1; + Console.WriteLine((right ? "ok " : "FAIL ") + Path.GetFileName(file) + ": " + (said.Count == 0 ? "lands" : string.Join(" ", said))); + foreach (string line in missing) + { + Console.WriteLine(" the output does not say: " + line); + } + } + + Console.WriteLine(cases.Length + " cases, " + wrong + " judged wrong"); + return cases.Length > 0 && wrong == 0 ? 0 : 1; + } + + static (bool, List<string>) Push(JsonElement root, List<Trailer> closing) + { + List<string> refusals = Closings.CheckCompletes(root.GetProperty("body").GetString()!, closing); + return (refusals.Count == 0, refusals); + } + + static (bool, List<string>) Date(JsonElement root) + { + string? refusal = Dates.Check(root.GetProperty("version").GetString()!, root.GetProperty("date").GetString()!, + DateOnly.ParseExact(root.GetProperty("landing").GetString()!, "yyyy-MM-dd", System.Globalization.CultureInfo.InvariantCulture)); + return (refusal is null, refusal is null ? [] : [refusal]); + } + + static (bool, List<string>) Landed(JsonElement root, List<Trailer> closing) + { + JsonElement states = root.GetProperty("states"); + Dictionary<int, string>? read = states.ValueKind == JsonValueKind.Null + ? null + : states.EnumerateObject().ToDictionary(static state => int.Parse(state.Name, System.Globalization.CultureInfo.InvariantCulture), static state => state.Value.GetString()!); + (int exit, List<string> said) = Closings.CheckClosed(closing, read); + return (exit == 0, said); + } +} diff --git a/eng/release.cs b/eng/release.cs index fa787677..56f6bb81 100644 --- a/eng/release.cs +++ b/eng/release.cs @@ -8,6 +8,9 @@ // dotnet run eng/release.cs -- --check [--base <ref>] // dotnet run eng/release.cs -- --plan [--dry-run] [--landing] [--since <sha>] [--fallback <dir>] [--offline] // dotnet run eng/release.cs -- --draft <version> +// dotnet run eng/release.cs -- --sbom <version> [--syft <syft>] [--binary <rid>]... [--image <p>=<json>]... +// [--today <yyyy-mm-dd>] [--from <dir> | --out <dir>] +// dotnet run eng/release.cs -- --sbom-fixtures tests/fixtures/release-sbom // dotnet run eng/release.cs -- --notes <version> --into <dir> // dotnet run eng/release.cs -- --fixtures tests/fixtures/releases // @@ -79,6 +82,26 @@ // and notes, for publish.yml. --fixtures runs every case under the directory: // each must fail, and say what its expect.txt says (tests/fixtures/releases/). // +// --- --sbom (#248) ----------------------------------------------------------------- +// +// Rewrites a pending descriptor's `sbom:` block and its `date:` in one edit, +// then CHANGELOG.md. Under ADR 0224 point 2 (decision D3) the SBOMs are created +// at the descriptor's date, so the two move together: the day the digests are +// written is today (UTC, or --today), eng/sbom.cs is run with --created that +// day, its sbom.txt replaces the block, and `date:` becomes that day +// (docs/releases.md rule 3, amended 2026-11-10). --syft, --binary and --image +// pass through to eng/sbom.cs, which writes to --out (artifacts/sbom by +// default). --from <dir> takes SBOMs already written +// there instead, and refuses them unless every one was created on that day. +// A tagged version is refused (rule 9), and so is a descriptor with no +// `sbom:` block, which keeps its date. eng/land.cs refuses a descriptor dated +// more than three days before the landing day and prints this command. +// +// --sbom-fixtures runs the rewrite over every case under the directory, each +// a descriptor.yaml, an sbom/ directory as eng/sbom.cs writes it, today.txt +// and, for a pass- case, expected.yaml; a fail- case must be refused +// (tests/fixtures/release-sbom/README.md). +// // Exit codes: 0 valid (or planned), 1 findings, 2 could not run. // // See docs/adr/0102-a-release-is-a-descriptor.md. @@ -111,6 +134,10 @@ bool dryRun = false; bool landing = false; bool offline = false; +string? today = null; +string? sbomFrom = null; +string? sbomOut = null; +List<string> sbomArguments = []; string[] arguments = args; for (int i = 0; i < arguments.Length; i++) @@ -120,7 +147,7 @@ case "--check" or "--plan": mode = arguments[i][2..]; break; - case "--draft" or "--notes" or "--fixtures" when i + 1 < arguments.Length: + case "--draft" or "--notes" or "--fixtures" or "--sbom" or "--sbom-fixtures" when i + 1 < arguments.Length: mode = arguments[i][2..]; modeArgument = arguments[++i]; break; @@ -151,6 +178,19 @@ case "--offline": offline = true; break; + case "--today" when i + 1 < arguments.Length: + today = arguments[++i]; + break; + case "--from" when i + 1 < arguments.Length: + sbomFrom = Path.GetFullPath(arguments[++i]); + break; + case "--out" when i + 1 < arguments.Length: + sbomOut = Path.GetFullPath(arguments[++i]); + break; + case "--syft" or "--binary" or "--image" when i + 1 < arguments.Length: + sbomArguments.Add(arguments[i]); + sbomArguments.Add(arguments[++i]); + break; default: Console.Error.WriteLine($"release: '{arguments[i]}' is not an option (see the header of eng/release.cs)."); return 2; @@ -168,6 +208,9 @@ "draft" => Draft(repository, modeArgument!), "notes" => Notes(repository, releasesPath, modeArgument!, into), "fixtures" => await Fixtures(repository, Path.GetFullPath(modeArgument!)), + "sbom" => Sbom(repository, releasesPath, modeArgument!, today, sbomFrom, sbomOut, sbomArguments, + regenerateChangelog: string.Equals(releasesPath, Path.Combine(repositoryRoot, ReleaseFormat.Directory), StringComparison.Ordinal)), + "sbom-fixtures" => SbomFixtures(Path.GetFullPath(modeArgument!)), _ => Usage(), }; } @@ -179,7 +222,7 @@ static int Usage() { - Console.Error.WriteLine("release: one of --check, --plan, --draft <version>, --notes <version> --into <dir>, --fixtures <dir>."); + Console.Error.WriteLine("release: one of --check, --plan, --draft <version>, --notes <version> --into <dir>, --sbom <version>, --fixtures <dir>, --sbom-fixtures <dir>."); return 2; } @@ -870,10 +913,10 @@ static int Draft(Repository repository, string requested) draft.Append(CultureInfo.InvariantCulture, $"# {key}\n"); } - draft.Append("# Every artefact's SBOM digest (ADR 0224 point 7), written after a restore with the descriptor's date:\n"); - draft.Append(CultureInfo.InvariantCulture, $"# dotnet run eng/sbom.cs -- --version {version} --created {DateTime.UtcNow:yyyy-MM-dd} --out artifacts/sbom \\\n"); - draft.Append("# --binary <rid>... --image linux/amd64=<syft.json> --image linux/arm64=<syft.json>\n"); - draft.Append("# and paste artifacts/sbom/sbom.txt below; publish.yml checks every line before anything is pushed.\n"); + draft.Append("# Every artefact's SBOM digest (ADR 0224 point 7), written after a restore. This rewrites the block\n"); + draft.Append("# and sets date: to the day it ran, which is the SBOMs' creation time (docs/releases.md rule 3):\n"); + draft.Append(CultureInfo.InvariantCulture, $"# dotnet run eng/release.cs -- --sbom {version} --syft <syft v1.54.0> --binary <rid>...\n"); + draft.Append("# publish.yml checks every line before anything is pushed.\n"); draft.Append("sbom:\n"); draft.Append(" - <kind: name sha256:digest, from sbom.txt>\n"); draft.Append("accepted-by: mailto:<the human who approves the pull request>\n"); @@ -992,6 +1035,106 @@ static async Task<int> Fixtures(Repository repository, string root) return repository.Exists("origin/main") ? "origin/main" : repository.Exists("main") ? "main" : null; } +// --- --sbom ------------------------------------------------------------------------ + +static int Sbom(Repository repository, string releasesPath, string requested, string? today, string? from, string? output, List<string> sbomArguments, bool regenerateChangelog) +{ + string version = requested.StartsWith('v') ? requested : "v" + requested; + string path = Path.Combine(releasesPath, version + ".yaml"); + + if (!File.Exists(path)) + { + Console.Error.WriteLine($"release: no descriptor at {repository.Relative(path)}."); + return 2; + } + + if (repository.Tags().Contains(version)) + { + Console.Error.WriteLine($"release: {version} is tagged, and a cut descriptor is never edited (docs/releases.md rule 9)."); + return 1; + } + + string day = today ?? DateTime.UtcNow.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture); + if (!DateOnly.TryParseExact(day, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out _)) + { + Console.Error.WriteLine($"release: --today '{day}' is not yyyy-mm-dd."); + return 2; + } + + if (from is not null && output is not null) + { + Console.Error.WriteLine("release: --from reads SBOMs already written and --out names where to write them; give one."); + return 2; + } + + string directory = from ?? output ?? Path.Combine(repository.Root, "artifacts", "sbom"); + if (from is null) + { + List<string> generate = ["run", "eng/sbom.cs", "--", "--version", version[1..], "--created", day, "--out", directory, .. sbomArguments]; + Console.WriteLine($"$ dotnet {string.Join(' ', generate)}"); + if (RunProcess("dotnet", generate) != 0) + { + Console.Error.WriteLine("release: eng/sbom.cs failed; the descriptor is unchanged."); + return 1; + } + } + + (string? rewritten, string? refusal) = SbomRewrite.Apply(File.ReadAllText(path), directory, day); + if (rewritten is null) + { + Console.Error.WriteLine($"release: {repository.Relative(path)}: {refusal}"); + return 1; + } + + File.WriteAllText(path, rewritten); + Console.WriteLine($"release: {repository.Relative(path)}: the sbom: block rewritten, and date: {day}."); + + if (!regenerateChangelog) + { + Console.WriteLine("release: --releases is not releases/, so CHANGELOG.md is left as it is."); + return 0; + } + + List<string> changelog = ["run", "eng/changelog.cs", "--", "--release", version]; + Console.WriteLine($"$ dotnet {string.Join(' ', changelog)}"); + return RunProcess("dotnet", changelog) == 0 ? 0 : 1; +} + +static int RunProcess(string file, List<string> arguments) +{ + ProcessStartInfo start = new() { FileName = file, UseShellExecute = false }; + foreach (string argument in arguments) + { + start.ArgumentList.Add(argument); + } + + using Process process = Process.Start(start) ?? throw new InvalidOperationException($"could not start {file}"); + process.WaitForExit(); + return process.ExitCode; +} + +static int SbomFixtures(string directory) +{ + int wrong = 0; + string[] cases = [.. Directory.GetDirectories(directory).Order(StringComparer.Ordinal)]; + foreach (string folder in cases) + { + string name = Path.GetFileName(folder); + string day = File.ReadAllText(Path.Combine(folder, "today.txt")).Trim(); + (string? rewritten, string? refusal) = SbomRewrite.Apply(File.ReadAllText(Path.Combine(folder, "descriptor.yaml")), Path.Combine(folder, "sbom"), day); + bool expected = name.StartsWith("pass-", StringComparison.Ordinal); + string expectedPath = Path.Combine(folder, "expected.yaml"); + bool right = expected + ? rewritten is not null && File.Exists(expectedPath) && rewritten == File.ReadAllText(expectedPath).ReplaceLineEndings("\n") + : rewritten is null; + wrong += right ? 0 : 1; + Console.WriteLine((right ? "ok " : "FAIL ") + name + ": " + (refusal ?? $"rewritten, date: {day}")); + } + + Console.WriteLine(cases.Length + " cases, " + wrong + " judged wrong"); + return cases.Length > 0 && wrong == 0 ? 0 : 1; +} + static string FindRepositoryRoot() { DirectoryInfo? directory = new(Directory.GetCurrentDirectory()); @@ -1238,3 +1381,60 @@ public Api(string token, string repository) return response.IsSuccessStatusCode ? JsonDocument.Parse(await response.Content.ReadAsStringAsync()) : null; } } + +// The rewrite itself, over text, so that the fixtures can judge it. +static class SbomRewrite +{ + static readonly Regex DateLine = new(@"^date:[ \t]*\S*[ \t]*$", RegexOptions.Multiline | RegexOptions.CultureInvariant); + + // The descriptor with its sbom: block replaced by <directory>/sbom.txt and + // its date: set to day, or why not. + public static (string? Rewritten, string? Refusal) Apply(string descriptor, string directory, string day) + { + string text = descriptor.ReplaceLineEndings("\n"); + string sbomTxt = Path.Combine(directory, "sbom.txt"); + if (!File.Exists(sbomTxt)) + { + return (null, $"no sbom.txt in {directory}; run eng/sbom.cs first, or drop --from."); + } + + string expectedCreated = day + "T00:00:00Z"; + foreach (string document in Directory.EnumerateFiles(directory, "*.spdx.json", SearchOption.AllDirectories).Order(StringComparer.Ordinal)) + { + using JsonDocument spdx = JsonDocument.Parse(File.ReadAllText(document)); + string? created = spdx.RootElement.TryGetProperty("creationInfo", out JsonElement info) && info.TryGetProperty("created", out JsonElement value) ? value.GetString() : null; + if (created != expectedCreated) + { + return (null, $"{Path.GetRelativePath(directory, document)} was created at {created ?? "no time"}, not {expectedCreated}; the digests and the date must be written on the same day (ADR 0224 point 2)."); + } + } + + if (DateLine.Matches(text).Count != 1) + { + return (null, "the descriptor has no single date: line."); + } + + List<string> lines = [.. text.Split('\n')]; + int start = lines.IndexOf("sbom:"); + if (start < 0) + { + return (null, "the descriptor has no sbom: block, so its digests and date stay as they are (format 1)."); + } + + int end = start + 1; + while (end < lines.Count && lines[end].StartsWith(" ", StringComparison.Ordinal)) + { + end++; + } + + string[] block = File.ReadAllText(sbomTxt).ReplaceLineEndings("\n").Split('\n', StringSplitOptions.RemoveEmptyEntries); + if (block.Length == 0 || block.Any(static line => !line.StartsWith(" - ", StringComparison.Ordinal))) + { + return (null, "sbom.txt is empty, or a line is not ` - <kind>: <name> sha256:<digest>`."); + } + + lines.RemoveRange(start + 1, end - start - 1); + lines.InsertRange(start + 1, block); + return (DateLine.Replace(string.Join('\n', lines), "date: " + day), null); + } +} diff --git a/tests/fixtures/land/README.md b/tests/fixtures/land/README.md new file mode 100644 index 00000000..72360c23 --- /dev/null +++ b/tests/fixtures/land/README.md @@ -0,0 +1,24 @@ +# `eng/land.cs`'s closing-trailer fixtures + +Cases for `eng/land.cs --fixtures` (#209). Each holds a `step`, the pushed +`commits` (`sha`, `message`) and either the pull request's `body` (`push`, the +`Completes:` check before a push) or the issues' `states` (`landed`, the check +after `--main` has moved `main`; `null` when GitHub cannot be read). `says` +lists text the output must contain. A `date` case holds a descriptor's +`version` and `date` and the `landing` day (#248). The pipeline's `land-fixtures` job runs +them on every build: a `pass-` case must pass and a `fail-` case be refused. + +| Case | Step | Why | +|---|---|---| +| `pass-completes-with-trailer` | push | `Completes: #1`, and a commit closes #1 | +| `fail-completes-without-trailer` | push | `Completes: #1`, and no commit closes it; refused naming #1 | +| `pass-completes-none` | push | `Completes: none`, no closing trailer | +| `fail-no-completes-line` | push | no `Completes:` line; one inside an HTML comment does not count | +| `fail-completes-malformed` | push | the template's placeholder left as it is | +| `pass-descriptor-trailer-counts` | push | a release descriptor's own `Closes #14` counts | +| `pass-landed-all-closed` | landed | every issue a trailer names is closed | +| `fail-landed-issue-still-open` | landed | #2 is still open; reported with its commit | +| `fail-landed-state-unreadable` | landed | GitHub cannot be read; non-zero, and it says so | +| `fail-date-four-days-before-landing` | date | dated four days before the landing day; refused, printing the `--sbom` command | +| `pass-date-three-days-before-landing` | date | three days is inside the window | +| `fail-date-missing` | date | no `date:` to judge | diff --git a/tests/fixtures/land/fail-completes-malformed.json b/tests/fixtures/land/fail-completes-malformed.json new file mode 100644 index 00000000..fc46683e --- /dev/null +++ b/tests/fixtures/land/fail-completes-malformed.json @@ -0,0 +1,8 @@ +{ + "step": "push", + "body": "Completes: #N, ...\n", + "commits": [], + "says": [ + "it is `Completes: none` or issues as #N" + ] +} diff --git a/tests/fixtures/land/fail-completes-without-trailer.json b/tests/fixtures/land/fail-completes-without-trailer.json new file mode 100644 index 00000000..b639c986 --- /dev/null +++ b/tests/fixtures/land/fail-completes-without-trailer.json @@ -0,0 +1,13 @@ +{ + "step": "push", + "body": "Completes: #1\n", + "commits": [ + { + "sha": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "message": "fix(x): the change\n\nThe body names #1 in prose.\n\nRefs #1\nSigned-off-by: A <a@example.org>\n" + } + ], + "says": [ + "completes #1, and no pushed commit closes it" + ] +} diff --git a/tests/fixtures/land/fail-date-four-days-before-landing.json b/tests/fixtures/land/fail-date-four-days-before-landing.json new file mode 100644 index 00000000..57ed5d90 --- /dev/null +++ b/tests/fixtures/land/fail-date-four-days-before-landing.json @@ -0,0 +1,10 @@ +{ + "step": "date", + "version": "v0.1.0-preview.21", + "date": "2026-11-06", + "landing": "2026-11-10", + "says": [ + "4 days before the landing day 2026-11-10", + "dotnet run eng/release.cs -- --sbom v0.1.0-preview.21" + ] +} diff --git a/tests/fixtures/land/fail-date-missing.json b/tests/fixtures/land/fail-date-missing.json new file mode 100644 index 00000000..b791bdb1 --- /dev/null +++ b/tests/fixtures/land/fail-date-missing.json @@ -0,0 +1,9 @@ +{ + "step": "date", + "version": "v0.1.0-preview.21", + "date": "", + "landing": "2026-11-10", + "says": [ + "has no date: as yyyy-mm-dd" + ] +} diff --git a/tests/fixtures/land/fail-landed-issue-still-open.json b/tests/fixtures/land/fail-landed-issue-still-open.json new file mode 100644 index 00000000..713d64b1 --- /dev/null +++ b/tests/fixtures/land/fail-landed-issue-still-open.json @@ -0,0 +1,20 @@ +{ + "step": "landed", + "commits": [ + { + "sha": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "message": "fix(x): the change\n\nThe body names #1 in prose.\n\nCloses #1\n" + }, + { + "sha": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "message": "fix(x): the change\n\nThe body names #1 in prose.\n\nCloses #2\n" + } + ], + "states": { + "1": "closed", + "2": "open" + }, + "says": [ + "#2 is still open, though bbbbbbbbbbbb closes it" + ] +} diff --git a/tests/fixtures/land/fail-landed-state-unreadable.json b/tests/fixtures/land/fail-landed-state-unreadable.json new file mode 100644 index 00000000..3d5bcfb9 --- /dev/null +++ b/tests/fixtures/land/fail-landed-state-unreadable.json @@ -0,0 +1,13 @@ +{ + "step": "landed", + "commits": [ + { + "sha": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "message": "fix(x): the change\n\nThe body names #1 in prose.\n\nCloses #1\n" + } + ], + "states": null, + "says": [ + "could not read the closed issues' state" + ] +} diff --git a/tests/fixtures/land/fail-no-completes-line.json b/tests/fixtures/land/fail-no-completes-line.json new file mode 100644 index 00000000..e0149c9d --- /dev/null +++ b/tests/fixtures/land/fail-no-completes-line.json @@ -0,0 +1,13 @@ +{ + "step": "push", + "body": "## What this changes\n\n<!-- Completes: #N, ... or Completes: none -->\n", + "commits": [ + { + "sha": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "message": "fix(x): the change\n\nThe body names #1 in prose.\n\nCloses #1\n" + } + ], + "says": [ + "has no `Completes:` line" + ] +} diff --git a/tests/fixtures/land/pass-completes-none.json b/tests/fixtures/land/pass-completes-none.json new file mode 100644 index 00000000..0f3981e8 --- /dev/null +++ b/tests/fixtures/land/pass-completes-none.json @@ -0,0 +1,10 @@ +{ + "step": "push", + "body": "Completes: none\n", + "commits": [ + { + "sha": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "message": "fix(x): the change\n\nThe body names #1 in prose.\n\nRefs #1\n" + } + ] +} diff --git a/tests/fixtures/land/pass-completes-with-trailer.json b/tests/fixtures/land/pass-completes-with-trailer.json new file mode 100644 index 00000000..b15d6fc0 --- /dev/null +++ b/tests/fixtures/land/pass-completes-with-trailer.json @@ -0,0 +1,10 @@ +{ + "step": "push", + "body": "## What this changes\n\nCompletes: #1\n", + "commits": [ + { + "sha": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "message": "fix(x): the change\n\nThe body names #1 in prose.\n\nCloses #1\nSigned-off-by: A <a@example.org>\n" + } + ] +} diff --git a/tests/fixtures/land/pass-date-three-days-before-landing.json b/tests/fixtures/land/pass-date-three-days-before-landing.json new file mode 100644 index 00000000..7dd52e4b --- /dev/null +++ b/tests/fixtures/land/pass-date-three-days-before-landing.json @@ -0,0 +1,6 @@ +{ + "step": "date", + "version": "v0.1.0-preview.21", + "date": "2026-11-07", + "landing": "2026-11-10" +} diff --git a/tests/fixtures/land/pass-descriptor-trailer-counts.json b/tests/fixtures/land/pass-descriptor-trailer-counts.json new file mode 100644 index 00000000..28c12b4f --- /dev/null +++ b/tests/fixtures/land/pass-descriptor-trailer-counts.json @@ -0,0 +1,14 @@ +{ + "step": "push", + "body": "Completes: #14, #202\n", + "commits": [ + { + "sha": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "message": "fix(x): the change\n\nThe body names #1 in prose.\n\nCloses #202\n" + }, + { + "sha": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "message": "release(v0.1.0-preview.20): milestone 9 part 1\n\nCloses #14\n" + } + ] +} diff --git a/tests/fixtures/land/pass-landed-all-closed.json b/tests/fixtures/land/pass-landed-all-closed.json new file mode 100644 index 00000000..9fd43581 --- /dev/null +++ b/tests/fixtures/land/pass-landed-all-closed.json @@ -0,0 +1,12 @@ +{ + "step": "landed", + "commits": [ + { + "sha": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "message": "fix(x): the change\n\nThe body names #1 in prose.\n\nCloses #1\n" + } + ], + "states": { + "1": "closed" + } +} diff --git a/tests/fixtures/release-sbom/README.md b/tests/fixtures/release-sbom/README.md new file mode 100644 index 00000000..dcba717e --- /dev/null +++ b/tests/fixtures/release-sbom/README.md @@ -0,0 +1,16 @@ +# `eng/release.cs --sbom`'s rewrite fixtures + +Cases for `eng/release.cs --sbom-fixtures` (#248). Each is a directory: a +`descriptor.yaml`; an `sbom/` directory as `eng/sbom.cs` writes it (its +`sbom.txt`, and SPDX documents whose `creationInfo.created` is what matters); +`today.txt`, the day the rewrite runs; and, for a `pass-` case, +`expected.yaml`, the descriptor the rewrite must write, byte for byte. A +`fail-` case must be refused. The pipeline's `release-sbom-fixtures` job runs +them on every build. + +| Case | Why | +|---|---| +| `pass-rewritten-on-a-later-day` | the block is replaced by `sbom.txt`, and `date:` moves from 2026-11-01 to the day the SBOMs were created | +| `pass-placeholder-from-draft` | `--draft`'s placeholder line is replaced the same way | +| `fail-sboms-created-another-day` | the SBOMs were created at 2026-11-01, not the rewrite's day: digests and date would disagree (ADR 0224 point 2) | +| `fail-no-sbom-block` | a format 1 descriptor has no block; its date stays | diff --git a/tests/fixtures/release-sbom/fail-no-sbom-block/descriptor.yaml b/tests/fixtures/release-sbom/fail-no-sbom-block/descriptor.yaml new file mode 100644 index 00000000..2be39489 --- /dev/null +++ b/tests/fixtures/release-sbom/fail-no-sbom-block/descriptor.yaml @@ -0,0 +1,10 @@ +format: 2 +version: v9.9.9-preview.1 +title: A fixture release +date: 2026-11-01 +basis: + - issue: 1 + - storage-format: 1 +accepted-by: mailto:maintainer@example.org +summary: | + A fixture. diff --git a/tests/fixtures/release-sbom/fail-no-sbom-block/sbom/nupkg/Varve.Iri.spdx.json b/tests/fixtures/release-sbom/fail-no-sbom-block/sbom/nupkg/Varve.Iri.spdx.json new file mode 100644 index 00000000..04958a26 --- /dev/null +++ b/tests/fixtures/release-sbom/fail-no-sbom-block/sbom/nupkg/Varve.Iri.spdx.json @@ -0,0 +1,7 @@ +{ + "spdxVersion": "SPDX-2.3", + "name": "Varve.Iri", + "creationInfo": { + "created": "2026-11-10T00:00:00Z" + } +} diff --git a/tests/fixtures/release-sbom/fail-no-sbom-block/sbom/nupkg/Varve.Rdf.spdx.json b/tests/fixtures/release-sbom/fail-no-sbom-block/sbom/nupkg/Varve.Rdf.spdx.json new file mode 100644 index 00000000..4c67e20e --- /dev/null +++ b/tests/fixtures/release-sbom/fail-no-sbom-block/sbom/nupkg/Varve.Rdf.spdx.json @@ -0,0 +1,7 @@ +{ + "spdxVersion": "SPDX-2.3", + "name": "Varve.Rdf", + "creationInfo": { + "created": "2026-11-10T00:00:00Z" + } +} diff --git a/tests/fixtures/release-sbom/fail-no-sbom-block/sbom/sbom.txt b/tests/fixtures/release-sbom/fail-no-sbom-block/sbom/sbom.txt new file mode 100644 index 00000000..520355c6 --- /dev/null +++ b/tests/fixtures/release-sbom/fail-no-sbom-block/sbom/sbom.txt @@ -0,0 +1,3 @@ + - image: ghcr.io/hafeok/varve sha256:3333333333333333333333333333333333333333333333333333333333333333 + - nupkg: Varve.Iri sha256:4444444444444444444444444444444444444444444444444444444444444444 + - nupkg: Varve.Rdf sha256:5555555555555555555555555555555555555555555555555555555555555555 diff --git a/tests/fixtures/release-sbom/fail-no-sbom-block/today.txt b/tests/fixtures/release-sbom/fail-no-sbom-block/today.txt new file mode 100644 index 00000000..0c36dfdc --- /dev/null +++ b/tests/fixtures/release-sbom/fail-no-sbom-block/today.txt @@ -0,0 +1 @@ +2026-11-10 diff --git a/tests/fixtures/release-sbom/fail-sboms-created-another-day/descriptor.yaml b/tests/fixtures/release-sbom/fail-sboms-created-another-day/descriptor.yaml new file mode 100644 index 00000000..8a7826fc --- /dev/null +++ b/tests/fixtures/release-sbom/fail-sboms-created-another-day/descriptor.yaml @@ -0,0 +1,14 @@ +format: 2 +version: v9.9.9-preview.1 +title: A fixture release +date: 2026-11-01 +basis: + - issue: 1 + - storage-format: 1 +# Every artefact's SBOM digest. +sbom: + - nupkg: Varve.Iri sha256:1111111111111111111111111111111111111111111111111111111111111111 + - nupkg: Varve.Rdf sha256:2222222222222222222222222222222222222222222222222222222222222222 +accepted-by: mailto:maintainer@example.org +summary: | + A fixture. diff --git a/tests/fixtures/release-sbom/fail-sboms-created-another-day/sbom/nupkg/Varve.Iri.spdx.json b/tests/fixtures/release-sbom/fail-sboms-created-another-day/sbom/nupkg/Varve.Iri.spdx.json new file mode 100644 index 00000000..0965fed2 --- /dev/null +++ b/tests/fixtures/release-sbom/fail-sboms-created-another-day/sbom/nupkg/Varve.Iri.spdx.json @@ -0,0 +1,7 @@ +{ + "spdxVersion": "SPDX-2.3", + "name": "Varve.Iri", + "creationInfo": { + "created": "2026-11-01T00:00:00Z" + } +} diff --git a/tests/fixtures/release-sbom/fail-sboms-created-another-day/sbom/nupkg/Varve.Rdf.spdx.json b/tests/fixtures/release-sbom/fail-sboms-created-another-day/sbom/nupkg/Varve.Rdf.spdx.json new file mode 100644 index 00000000..d03c0669 --- /dev/null +++ b/tests/fixtures/release-sbom/fail-sboms-created-another-day/sbom/nupkg/Varve.Rdf.spdx.json @@ -0,0 +1,7 @@ +{ + "spdxVersion": "SPDX-2.3", + "name": "Varve.Rdf", + "creationInfo": { + "created": "2026-11-01T00:00:00Z" + } +} diff --git a/tests/fixtures/release-sbom/fail-sboms-created-another-day/sbom/sbom.txt b/tests/fixtures/release-sbom/fail-sboms-created-another-day/sbom/sbom.txt new file mode 100644 index 00000000..520355c6 --- /dev/null +++ b/tests/fixtures/release-sbom/fail-sboms-created-another-day/sbom/sbom.txt @@ -0,0 +1,3 @@ + - image: ghcr.io/hafeok/varve sha256:3333333333333333333333333333333333333333333333333333333333333333 + - nupkg: Varve.Iri sha256:4444444444444444444444444444444444444444444444444444444444444444 + - nupkg: Varve.Rdf sha256:5555555555555555555555555555555555555555555555555555555555555555 diff --git a/tests/fixtures/release-sbom/fail-sboms-created-another-day/today.txt b/tests/fixtures/release-sbom/fail-sboms-created-another-day/today.txt new file mode 100644 index 00000000..0c36dfdc --- /dev/null +++ b/tests/fixtures/release-sbom/fail-sboms-created-another-day/today.txt @@ -0,0 +1 @@ +2026-11-10 diff --git a/tests/fixtures/release-sbom/pass-placeholder-from-draft/descriptor.yaml b/tests/fixtures/release-sbom/pass-placeholder-from-draft/descriptor.yaml new file mode 100644 index 00000000..17707936 --- /dev/null +++ b/tests/fixtures/release-sbom/pass-placeholder-from-draft/descriptor.yaml @@ -0,0 +1,13 @@ +format: 2 +version: v9.9.9-preview.1 +title: A fixture release +date: 2026-11-01 +basis: + - issue: 1 + - storage-format: 1 +# Every artefact's SBOM digest. +sbom: + - <kind: name sha256:digest, from sbom.txt> +accepted-by: mailto:maintainer@example.org +summary: | + A fixture. diff --git a/tests/fixtures/release-sbom/pass-placeholder-from-draft/expected.yaml b/tests/fixtures/release-sbom/pass-placeholder-from-draft/expected.yaml new file mode 100644 index 00000000..05d5f341 --- /dev/null +++ b/tests/fixtures/release-sbom/pass-placeholder-from-draft/expected.yaml @@ -0,0 +1,15 @@ +format: 2 +version: v9.9.9-preview.1 +title: A fixture release +date: 2026-11-10 +basis: + - issue: 1 + - storage-format: 1 +# Every artefact's SBOM digest. +sbom: + - image: ghcr.io/hafeok/varve sha256:3333333333333333333333333333333333333333333333333333333333333333 + - nupkg: Varve.Iri sha256:4444444444444444444444444444444444444444444444444444444444444444 + - nupkg: Varve.Rdf sha256:5555555555555555555555555555555555555555555555555555555555555555 +accepted-by: mailto:maintainer@example.org +summary: | + A fixture. diff --git a/tests/fixtures/release-sbom/pass-placeholder-from-draft/sbom/nupkg/Varve.Iri.spdx.json b/tests/fixtures/release-sbom/pass-placeholder-from-draft/sbom/nupkg/Varve.Iri.spdx.json new file mode 100644 index 00000000..04958a26 --- /dev/null +++ b/tests/fixtures/release-sbom/pass-placeholder-from-draft/sbom/nupkg/Varve.Iri.spdx.json @@ -0,0 +1,7 @@ +{ + "spdxVersion": "SPDX-2.3", + "name": "Varve.Iri", + "creationInfo": { + "created": "2026-11-10T00:00:00Z" + } +} diff --git a/tests/fixtures/release-sbom/pass-placeholder-from-draft/sbom/nupkg/Varve.Rdf.spdx.json b/tests/fixtures/release-sbom/pass-placeholder-from-draft/sbom/nupkg/Varve.Rdf.spdx.json new file mode 100644 index 00000000..4c67e20e --- /dev/null +++ b/tests/fixtures/release-sbom/pass-placeholder-from-draft/sbom/nupkg/Varve.Rdf.spdx.json @@ -0,0 +1,7 @@ +{ + "spdxVersion": "SPDX-2.3", + "name": "Varve.Rdf", + "creationInfo": { + "created": "2026-11-10T00:00:00Z" + } +} diff --git a/tests/fixtures/release-sbom/pass-placeholder-from-draft/sbom/sbom.txt b/tests/fixtures/release-sbom/pass-placeholder-from-draft/sbom/sbom.txt new file mode 100644 index 00000000..520355c6 --- /dev/null +++ b/tests/fixtures/release-sbom/pass-placeholder-from-draft/sbom/sbom.txt @@ -0,0 +1,3 @@ + - image: ghcr.io/hafeok/varve sha256:3333333333333333333333333333333333333333333333333333333333333333 + - nupkg: Varve.Iri sha256:4444444444444444444444444444444444444444444444444444444444444444 + - nupkg: Varve.Rdf sha256:5555555555555555555555555555555555555555555555555555555555555555 diff --git a/tests/fixtures/release-sbom/pass-placeholder-from-draft/today.txt b/tests/fixtures/release-sbom/pass-placeholder-from-draft/today.txt new file mode 100644 index 00000000..0c36dfdc --- /dev/null +++ b/tests/fixtures/release-sbom/pass-placeholder-from-draft/today.txt @@ -0,0 +1 @@ +2026-11-10 diff --git a/tests/fixtures/release-sbom/pass-rewritten-on-a-later-day/descriptor.yaml b/tests/fixtures/release-sbom/pass-rewritten-on-a-later-day/descriptor.yaml new file mode 100644 index 00000000..8a7826fc --- /dev/null +++ b/tests/fixtures/release-sbom/pass-rewritten-on-a-later-day/descriptor.yaml @@ -0,0 +1,14 @@ +format: 2 +version: v9.9.9-preview.1 +title: A fixture release +date: 2026-11-01 +basis: + - issue: 1 + - storage-format: 1 +# Every artefact's SBOM digest. +sbom: + - nupkg: Varve.Iri sha256:1111111111111111111111111111111111111111111111111111111111111111 + - nupkg: Varve.Rdf sha256:2222222222222222222222222222222222222222222222222222222222222222 +accepted-by: mailto:maintainer@example.org +summary: | + A fixture. diff --git a/tests/fixtures/release-sbom/pass-rewritten-on-a-later-day/expected.yaml b/tests/fixtures/release-sbom/pass-rewritten-on-a-later-day/expected.yaml new file mode 100644 index 00000000..05d5f341 --- /dev/null +++ b/tests/fixtures/release-sbom/pass-rewritten-on-a-later-day/expected.yaml @@ -0,0 +1,15 @@ +format: 2 +version: v9.9.9-preview.1 +title: A fixture release +date: 2026-11-10 +basis: + - issue: 1 + - storage-format: 1 +# Every artefact's SBOM digest. +sbom: + - image: ghcr.io/hafeok/varve sha256:3333333333333333333333333333333333333333333333333333333333333333 + - nupkg: Varve.Iri sha256:4444444444444444444444444444444444444444444444444444444444444444 + - nupkg: Varve.Rdf sha256:5555555555555555555555555555555555555555555555555555555555555555 +accepted-by: mailto:maintainer@example.org +summary: | + A fixture. diff --git a/tests/fixtures/release-sbom/pass-rewritten-on-a-later-day/sbom/nupkg/Varve.Iri.spdx.json b/tests/fixtures/release-sbom/pass-rewritten-on-a-later-day/sbom/nupkg/Varve.Iri.spdx.json new file mode 100644 index 00000000..04958a26 --- /dev/null +++ b/tests/fixtures/release-sbom/pass-rewritten-on-a-later-day/sbom/nupkg/Varve.Iri.spdx.json @@ -0,0 +1,7 @@ +{ + "spdxVersion": "SPDX-2.3", + "name": "Varve.Iri", + "creationInfo": { + "created": "2026-11-10T00:00:00Z" + } +} diff --git a/tests/fixtures/release-sbom/pass-rewritten-on-a-later-day/sbom/nupkg/Varve.Rdf.spdx.json b/tests/fixtures/release-sbom/pass-rewritten-on-a-later-day/sbom/nupkg/Varve.Rdf.spdx.json new file mode 100644 index 00000000..4c67e20e --- /dev/null +++ b/tests/fixtures/release-sbom/pass-rewritten-on-a-later-day/sbom/nupkg/Varve.Rdf.spdx.json @@ -0,0 +1,7 @@ +{ + "spdxVersion": "SPDX-2.3", + "name": "Varve.Rdf", + "creationInfo": { + "created": "2026-11-10T00:00:00Z" + } +} diff --git a/tests/fixtures/release-sbom/pass-rewritten-on-a-later-day/sbom/sbom.txt b/tests/fixtures/release-sbom/pass-rewritten-on-a-later-day/sbom/sbom.txt new file mode 100644 index 00000000..520355c6 --- /dev/null +++ b/tests/fixtures/release-sbom/pass-rewritten-on-a-later-day/sbom/sbom.txt @@ -0,0 +1,3 @@ + - image: ghcr.io/hafeok/varve sha256:3333333333333333333333333333333333333333333333333333333333333333 + - nupkg: Varve.Iri sha256:4444444444444444444444444444444444444444444444444444444444444444 + - nupkg: Varve.Rdf sha256:5555555555555555555555555555555555555555555555555555555555555555 diff --git a/tests/fixtures/release-sbom/pass-rewritten-on-a-later-day/today.txt b/tests/fixtures/release-sbom/pass-rewritten-on-a-later-day/today.txt new file mode 100644 index 00000000..0c36dfdc --- /dev/null +++ b/tests/fixtures/release-sbom/pass-rewritten-on-a-later-day/today.txt @@ -0,0 +1 @@ +2026-11-10