diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e217dea..7b84b66 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -43,6 +43,23 @@ jobs: - name: Provider component fixture inventory run: cargo +"${{ matrix.rust-version }}" xtask provider-component-fixtures --check + release-dates: + name: release-dates (git tag reconciliation) + runs-on: ubuntu-latest + steps: + # The reconciliation compares recorded dates against the tags that + # published them, so this job needs the tags. The default shallow + # checkout fetches none, which is why this cannot ride along on the + # matrix job above. + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 + with: + persist-credentials: false + fetch-depth: 0 + - name: Install Rust + run: rustup toolchain install stable --profile minimal + - name: Reconcile release dates against git tags + run: cargo +stable xtask release-dates --check + supply-chain: name: supply-chain (cargo-deny) runs-on: ubuntu-latest diff --git a/CHANGELOG.md b/CHANGELOG.md index 73faba3..381519a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,132 @@ versions still track specification maturity rather than a released product. target obstructions, exact verifier acceptance, safe provider roles, shared domain-framed artifact identities, and rollback-safe package/report publication. +- Release dates across `CHANGELOG.md`, `docs/topics/release-process/policy.toml`, + `docs/releases/*.md`, and the `xtask` release guards now record the actual git + tag dates (2026-06-21 through 2026-06-30) instead of the planned biweekly + schedule that ran to 2026-11-04. The `target_date` field keeps its name but now + holds the date the release was tagged, so `RELEASE-REQ-008` is restated + accordingly. The tag date is not the GitHub Release publication timestamp: + `v0.4.0-alpha.1` was tagged 2026-06-25 and published later the same day, and + release notes keep that publication timestamp separately. Tag dates are read in + UTC so a local run and a CI run agree; `v0.4.0-alpha.1` is the one tag where + that matters, resolving to 2026-06-24 in PDT and 2026-06-25 in UTC. +- `cargo xtask release-prep` no longer derives the scaffolded date by adding + fourteen days to the last recorded release. That extrapolation assumed + `target_date` held a planned date on a biweekly cadence; now that it records + when a release was tagged, extrapolating produced a date in the past. The + command takes `--date YYYY-MM-DD` and otherwise uses today's UTC date. +- The local `cargo xtask verify` gate now schedules one default workspace test + pass, which already includes doctests, instead of repeating every workspace + doctest in a second Cargo invocation. +- Selected provider component identities now borrow their validated manifest + directly rather than the temporary proof handle used to authorize selection, + so callers can discard that handle after obtaining the opaque selection. +- Canonical-CBOR encoding and decoding now accept at most 128 nested values and + return the stable `NestingLimitExceeded` kind beyond that bound. Provider + artifact validation uses the same bounded decoder before digest computation. +- The parser now accepts first-class obstruction-strand source syntax: + `require ... else continue obstructed { reason: ... }`. The form is preserved + as a distinct `RequireElseArm::ContinueObstructed` source AST arm, requires + exactly one `reason` field, rejects duplicate `reason` fields, and remains + contextual to a `require ... else` arm. Helper-shaped constructors such as + `continueInObstructedStrand(...)` remain ordinary terminal obstruction + targets. Echo receipts, runtime execution, and editor projection remain + deferred. +- Core lowering now represents `require` statements as explicit Core require + nodes with terminal and preserved-obstruction failure arms. The canonical Core + preimage distinguishes `else ` from + `else continue obstructed { ... }`, binds stable reason kinds and canonical + payload fields, rejects duplicate payload fields before Core digesting, and + keeps non-semantic formatting out of Core digests. Runtime receipt behavior + for obstruction strands remains deferred. +- Echo Target IR lowering now represents supported Core `require` guards as + explicit Target IR requirements with terminal and `continueObstructed` failure + dispositions. Canonical Target IR bytes and digests now bind requirement + predicates, reason kinds, reason payload values, and terminal-vs-preserved + disposition, while targets without requirement support reject with a stable + `UnsupportedTargetFeature` failure before artifact emission. Requirements + after a target step, including requirements whose predicate or reason payload + reads a target step output, reject until Target IR owns an ordered or + step-attached guard shape. Echo runtime receipts, admission, scheduler + counterfactuals, and editor projection remain deferred. +- The obstruction-strands design note now formalizes the cross-project taxonomy + separating not-admitted scheduler counterfactuals, admitted obstructed strands, + and hard rejections. The taxonomy records authority boundaries only; it does + not add Edict-owned runtime execution, scheduler exploration, Graft + projection, jedit display, Continuum settlement, or XYPH settlement behavior. +- Added the `edict` CLI `project` operation for editor-facing JSONL + projection over dirty source records. It can emit syntax spans, diagnostics, + Core review JSON plus canonical Core digest, and Echo Target IR review JSON + plus canonical Target IR digest without requiring the source to exist on disk; + compiler and lowering failures are structured projection data on stdout, not + CLI transport failures. The projection review JSON is not a canonical hash + contract. Syntax-only lexical failures now emit visible diagnostics projection + data, and CLI-input failures for known `project` requests report + `command: "project"` in their diagnostic and status records. Explicit `null` + values for object-valued compiler settings such as `compilerContext` and + `target` are rejected before serde can treat them as absent values, and the + settings schema now rejects empty `project` emit lists. The help record now + scopes exit code `1` to `check` diagnostics because `project` compiler + diagnostics are projection data and exit `0`. +- The `edict` CLI now bounds stdin before request parsing with a default 8 MiB + cap and an `EDICT_CLI_MAX_STDIN_BYTES` override. Over-limit input fails with + the stable `InputTooLarge` CLI diagnostic and exit 2, pinned by + `CLI-REQ-010` / `CLI-TP-016` and `fixtures/cli/12-input-too-large`. +- The `edict` CLI now documents its trusted local request boundary and accepts + optional compiler setting `inputRoot` to confine path, path-list, directory, + and glob inputs. Inputs resolving outside that root fail with + `InputPathOutsideRoot`, exit 2, and are pinned by `CLI-REQ-011` / + `CLI-TP-017` plus `fixtures/cli/13-input-root-outside`; explicit JSON `null` + for `inputRoot` is rejected as `InvalidSettings`, and non-file glob matches + are skipped before root-confined canonicalization. +- The `edict` CLI now builds its JSONL check-result, diagnostic, status, and + info records from typed `Serialize` structs instead of post-construction + `serde_json::Value` mutation, while preserving the existing byte-for-byte + golden output. +- The `edict-cli` production targets now deny `clippy::unwrap_used` and + `clippy::expect_used`, and the parser's `self.expect` helper is documented as + a fallible token-matching combinator rather than a panic primitive. +- CI now includes a dedicated `cargo deny check` supply-chain job backed by + `deny.toml`, enforcing RustSec advisories, yanked crates, license allowlisting, + duplicate-version warnings, and source restrictions. +- Raised the Rust MSRV to 1.94 for Wasmtime 46.0.1, with every workspace package + inheriting that value into Cargo metadata. Wasmtime is isolated to the private + provider host with default features disabled, no `wasmtime-wasi`, an executable + direct/resolved feature ratchet, reviewed permissive license additions, and + cargo-deny coverage for both the root and nested fixture guest lockfiles. +- Directory expansion in the `edict` CLI no longer allocates a temporary dotted + extension string per visited file; behavior and golden output are unchanged. +- Added `cargo xtask cli-goldens --check/--write` and wired check mode into + `cargo xtask verify`, giving the CLI golden corpus the same check/write + regeneration path as the Core, Target IR, and bundle goldens. The CLI golden + runner resolves the `edict` binary through Cargo metadata so custom target + directories are honored. +- Added `cargo xtask release-prep ` to scaffold the mechanical release + prep surfaces that must move together: workspace package versions, lockfile + package versions, dated changelog section, release policy boundary block, + release notes stub, boundary test stub, changelog date guard entry, and paired + release-process test-plan rows. Generated boundary tests now require operators + to replace scaffolded scope/non-goal placeholders before the branch can pass. +- Added a root `ARCHITECTURE.md` workspace map covering current crate + responsibilities, dependency direction, the `edict-syntax` crate-scope caveat, + and current non-claims. +- Added a Core IR canonical encoding explainer covering the canonical value + model, canonical CBOR subset, Core digest frame, reviewed golden fixtures, and + byte/hash change discipline. +- Recorded the crate-scope decision to prefer an eventual layered split behind + an umbrella crate over a simple `edict-syntax` rename, while documenting the + current crate-scope caveat in `ARCHITECTURE.md`. +- Recorded the schema-as-source-of-truth codegen decision: defer generator work + until cross-language drift or fixture-authoring pain is measurable, and do not + reintroduce GraphQL semantics as the contract source. +- Split `xtask` out of its former single-file shape: command dispatch, + contract checks, golden management, release scaffolding, shared utilities, and + harness tests now live in focused `xtask/src/*.rs` modules with command + behavior preserved. +- Marked `v0.11.0-alpha.1` as published in the release-process contract and + release notes, recording the immutable tag, workflow evidence, milestone + closure, release URL, and no-crates publication evidence. ### Added @@ -238,121 +364,7 @@ versions still track specification maturity rather than a released product. and malformed canonical-ABI lifting. Both Rust CI matrix jobs check the inventory explicitly. -### Changed - -- The local `cargo xtask verify` gate now schedules one default workspace test - pass, which already includes doctests, instead of repeating every workspace - doctest in a second Cargo invocation. -- Selected provider component identities now borrow their validated manifest - directly rather than the temporary proof handle used to authorize selection, - so callers can discard that handle after obtaining the opaque selection. -- Canonical-CBOR encoding and decoding now accept at most 128 nested values and - return the stable `NestingLimitExceeded` kind beyond that bound. Provider - artifact validation uses the same bounded decoder before digest computation. -- The parser now accepts first-class obstruction-strand source syntax: - `require ... else continue obstructed { reason: ... }`. The form is preserved - as a distinct `RequireElseArm::ContinueObstructed` source AST arm, requires - exactly one `reason` field, rejects duplicate `reason` fields, and remains - contextual to a `require ... else` arm. Helper-shaped constructors such as - `continueInObstructedStrand(...)` remain ordinary terminal obstruction - targets. Echo receipts, runtime execution, and editor projection remain - deferred. -- Core lowering now represents `require` statements as explicit Core require - nodes with terminal and preserved-obstruction failure arms. The canonical Core - preimage distinguishes `else ` from - `else continue obstructed { ... }`, binds stable reason kinds and canonical - payload fields, rejects duplicate payload fields before Core digesting, and - keeps non-semantic formatting out of Core digests. Runtime receipt behavior - for obstruction strands remains deferred. -- Echo Target IR lowering now represents supported Core `require` guards as - explicit Target IR requirements with terminal and `continueObstructed` failure - dispositions. Canonical Target IR bytes and digests now bind requirement - predicates, reason kinds, reason payload values, and terminal-vs-preserved - disposition, while targets without requirement support reject with a stable - `UnsupportedTargetFeature` failure before artifact emission. Requirements - after a target step, including requirements whose predicate or reason payload - reads a target step output, reject until Target IR owns an ordered or - step-attached guard shape. Echo runtime receipts, admission, scheduler - counterfactuals, and editor projection remain deferred. -- The obstruction-strands design note now formalizes the cross-project taxonomy - separating not-admitted scheduler counterfactuals, admitted obstructed strands, - and hard rejections. The taxonomy records authority boundaries only; it does - not add Edict-owned runtime execution, scheduler exploration, Graft - projection, jedit display, Continuum settlement, or XYPH settlement behavior. -- Added the `edict` CLI `project` operation for editor-facing JSONL - projection over dirty source records. It can emit syntax spans, diagnostics, - Core review JSON plus canonical Core digest, and Echo Target IR review JSON - plus canonical Target IR digest without requiring the source to exist on disk; - compiler and lowering failures are structured projection data on stdout, not - CLI transport failures. The projection review JSON is not a canonical hash - contract. Syntax-only lexical failures now emit visible diagnostics projection - data, and CLI-input failures for known `project` requests report - `command: "project"` in their diagnostic and status records. Explicit `null` - values for object-valued compiler settings such as `compilerContext` and - `target` are rejected before serde can treat them as absent values, and the - settings schema now rejects empty `project` emit lists. The help record now - scopes exit code `1` to `check` diagnostics because `project` compiler - diagnostics are projection data and exit `0`. -- The `edict` CLI now bounds stdin before request parsing with a default 8 MiB - cap and an `EDICT_CLI_MAX_STDIN_BYTES` override. Over-limit input fails with - the stable `InputTooLarge` CLI diagnostic and exit 2, pinned by - `CLI-REQ-010` / `CLI-TP-016` and `fixtures/cli/12-input-too-large`. -- The `edict` CLI now documents its trusted local request boundary and accepts - optional compiler setting `inputRoot` to confine path, path-list, directory, - and glob inputs. Inputs resolving outside that root fail with - `InputPathOutsideRoot`, exit 2, and are pinned by `CLI-REQ-011` / - `CLI-TP-017` plus `fixtures/cli/13-input-root-outside`; explicit JSON `null` - for `inputRoot` is rejected as `InvalidSettings`, and non-file glob matches - are skipped before root-confined canonicalization. -- The `edict` CLI now builds its JSONL check-result, diagnostic, status, and - info records from typed `Serialize` structs instead of post-construction - `serde_json::Value` mutation, while preserving the existing byte-for-byte - golden output. -- The `edict-cli` production targets now deny `clippy::unwrap_used` and - `clippy::expect_used`, and the parser's `self.expect` helper is documented as - a fallible token-matching combinator rather than a panic primitive. -- CI now includes a dedicated `cargo deny check` supply-chain job backed by - `deny.toml`, enforcing RustSec advisories, yanked crates, license allowlisting, - duplicate-version warnings, and source restrictions. -- Raised the Rust MSRV to 1.94 for Wasmtime 46.0.1, with every workspace package - inheriting that value into Cargo metadata. Wasmtime is isolated to the private - provider host with default features disabled, no `wasmtime-wasi`, an executable - direct/resolved feature ratchet, reviewed permissive license additions, and - cargo-deny coverage for both the root and nested fixture guest lockfiles. -- Directory expansion in the `edict` CLI no longer allocates a temporary dotted - extension string per visited file; behavior and golden output are unchanged. -- Added `cargo xtask cli-goldens --check/--write` and wired check mode into - `cargo xtask verify`, giving the CLI golden corpus the same check/write - regeneration path as the Core, Target IR, and bundle goldens. The CLI golden - runner resolves the `edict` binary through Cargo metadata so custom target - directories are honored. -- Added `cargo xtask release-prep ` to scaffold the mechanical release - prep surfaces that must move together: workspace package versions, lockfile - package versions, dated changelog section, release policy boundary block, - release notes stub, boundary test stub, changelog date guard entry, and paired - release-process test-plan rows. Generated boundary tests now require operators - to replace scaffolded scope/non-goal placeholders before the branch can pass. -- Added a root `ARCHITECTURE.md` workspace map covering current crate - responsibilities, dependency direction, the `edict-syntax` crate-scope caveat, - and current non-claims. -- Added a Core IR canonical encoding explainer covering the canonical value - model, canonical CBOR subset, Core digest frame, reviewed golden fixtures, and - byte/hash change discipline. -- Recorded the crate-scope decision to prefer an eventual layered split behind - an umbrella crate over a simple `edict-syntax` rename, while documenting the - current crate-scope caveat in `ARCHITECTURE.md`. -- Recorded the schema-as-source-of-truth codegen decision: defer generator work - until cross-language drift or fixture-authoring pain is measurable, and do not - reintroduce GraphQL semantics as the contract source. -- Split `xtask` out of its former single-file shape: command dispatch, - contract checks, golden management, release scaffolding, shared utilities, and - harness tests now live in focused `xtask/src/*.rs` modules with command - behavior preserved. -- Marked `v0.11.0-alpha.1` as published in the release-process contract and - release notes, recording the immutable tag, workflow evidence, milestone - closure, release URL, and no-crates publication evidence. - -## [v0.11.0-alpha.1] - 2026-11-04 +## [v0.11.0-alpha.1] - 2026-06-30 ### Added @@ -406,7 +418,7 @@ versions still track specification maturity rather than a released product. recorded durable release evidence for its tag, workflow runs, milestone closure, and no-crates publication boundary. -## [v0.10.0-alpha.1] - 2026-10-21 +## [v0.10.0-alpha.1] - 2026-06-29 ### Added @@ -446,7 +458,7 @@ versions still track specification maturity rather than a released product. recorded durable release evidence for its tag, workflow runs, milestone closure, and no-crates publication boundary. -## [v0.9.0-alpha.1] - 2026-10-07 +## [v0.9.0-alpha.1] - 2026-06-28 ### Added @@ -477,7 +489,7 @@ versions still track specification maturity rather than a released product. recorded durable release evidence for its tag, workflow runs, milestone closure, and no-crates publication boundary. -## [v0.8.0-alpha.1] - 2026-09-23 +## [v0.8.0-alpha.1] - 2026-06-28 ### Added @@ -495,7 +507,7 @@ versions still track specification maturity rather than a released product. recorded durable release evidence for its tag, workflow runs, milestone closure, and no-crates publication boundary. -## [v0.7.0-alpha.1] - 2026-09-09 +## [v0.7.0-alpha.1] - 2026-06-27 ### Added @@ -518,7 +530,7 @@ versions still track specification maturity rather than a released product. - Added the review-process topic shelf and structured CodeRabbit-to-Codex fallback policy for review-bot outages, rate limits, and credit exhaustion. -## [v0.6.0-alpha.1] - 2026-08-26 +## [v0.6.0-alpha.1] - 2026-06-26 ### Added @@ -542,7 +554,7 @@ versions still track specification maturity rather than a released product. - Added a release-prep topic-shelf audit gate requiring `docs/topics/` coverage and accuracy to both meet at least 90% before release. -## [v0.5.0-alpha.1] - 2026-08-12 +## [v0.5.0-alpha.1] - 2026-06-25 ### Added @@ -559,7 +571,7 @@ versions still track specification maturity rather than a released product. - Added the admission topic shelf for the Edict/Continuum admission-boundary contract and verification matrix. -## [v0.4.0-alpha.1] - 2026-07-29 +## [v0.4.0-alpha.1] - 2026-06-25 ### Added @@ -596,7 +608,7 @@ versions still track specification maturity rather than a released product. native support, exactly one direct adapter, or unsupported. General composite adapter-chain search remains future v2 design work. -## [v0.3.0-alpha.1] - 2026-07-15 +## [v0.3.0-alpha.1] - 2026-06-24 ### Added @@ -645,7 +657,7 @@ versions still track specification maturity rather than a released product. parameter renaming stays hash-invariant while Core local identity mutations still change canonical bytes and digests. -## [v0.2.0-alpha.1] - 2026-07-01 +## [v0.2.0-alpha.1] - 2026-06-21 ### Added @@ -664,7 +676,7 @@ versions still track specification maturity rather than a released product. - Relaxed Markdown heading duplication checks to allow changelog section headings to repeat across different release versions. -## [v0.1.0-alpha.1] - 2026-06-24 +## [v0.1.0-alpha.1] - 2026-06-21 ### Added diff --git a/docs/releases/v0.1.0-alpha.1.md b/docs/releases/v0.1.0-alpha.1.md index a1519f7..5dd9605 100644 --- a/docs/releases/v0.1.0-alpha.1.md +++ b/docs/releases/v0.1.0-alpha.1.md @@ -1,6 +1,6 @@ # v0.1.0-alpha.1 Release Notes -Target date: 2026-06-24 +Target date: 2026-06-21 Status: published as a GitHub prerelease from the signed `v0.1.0-alpha.1` tag on 2026-06-21. diff --git a/docs/releases/v0.10.0-alpha.1.md b/docs/releases/v0.10.0-alpha.1.md index c3f453c..363c4e9 100644 --- a/docs/releases/v0.10.0-alpha.1.md +++ b/docs/releases/v0.10.0-alpha.1.md @@ -1,6 +1,6 @@ # v0.10.0-alpha.1 Release Notes -Target date: 2026-10-21 +Target date: 2026-06-29 Status: published GitHub prerelease notes for `v0.10.0-alpha.1`, with post-publication evidence recorded on `main`. diff --git a/docs/releases/v0.11.0-alpha.1.md b/docs/releases/v0.11.0-alpha.1.md index 705a282..4612dbb 100644 --- a/docs/releases/v0.11.0-alpha.1.md +++ b/docs/releases/v0.11.0-alpha.1.md @@ -1,6 +1,6 @@ # v0.11.0-alpha.1 Release Notes -Target date: 2026-11-04 +Target date: 2026-06-30 Status: published GitHub prerelease notes for `v0.11.0-alpha.1`, with post-publication evidence recorded on `main`. diff --git a/docs/releases/v0.2.0-alpha.1.md b/docs/releases/v0.2.0-alpha.1.md index e378a24..7302ef9 100644 --- a/docs/releases/v0.2.0-alpha.1.md +++ b/docs/releases/v0.2.0-alpha.1.md @@ -1,6 +1,6 @@ # v0.2.0-alpha.1 Release Notes -Target date: 2026-07-01 +Target date: 2026-06-21 Status: published as a GitHub prerelease from the signed `v0.2.0-alpha.1` tag on 2026-06-21. diff --git a/docs/releases/v0.3.0-alpha.1.md b/docs/releases/v0.3.0-alpha.1.md index 90dd52d..bf023ba 100644 --- a/docs/releases/v0.3.0-alpha.1.md +++ b/docs/releases/v0.3.0-alpha.1.md @@ -1,6 +1,6 @@ # v0.3.0-alpha.1 Release Notes -Target date: 2026-07-15 +Target date: 2026-06-24 Status: published as a GitHub prerelease from the signed `v0.3.0-alpha.1` tag targeting `4ea3d993f74490b495fe6e6a9ec2d52f889ccceb`. diff --git a/docs/releases/v0.4.0-alpha.1.md b/docs/releases/v0.4.0-alpha.1.md index 3b71ab3..769500b 100644 --- a/docs/releases/v0.4.0-alpha.1.md +++ b/docs/releases/v0.4.0-alpha.1.md @@ -1,6 +1,6 @@ # v0.4.0-alpha.1 Release Notes -Target date: 2026-07-29 +Target date: 2026-06-25 Status: published as a GitHub prerelease from the signed `v0.4.0-alpha.1` tag targeting `65c80ce4660b384ebf9fd482c59fff402f34d47b`. diff --git a/docs/releases/v0.5.0-alpha.1.md b/docs/releases/v0.5.0-alpha.1.md index 148659a..7c7354e 100644 --- a/docs/releases/v0.5.0-alpha.1.md +++ b/docs/releases/v0.5.0-alpha.1.md @@ -1,6 +1,6 @@ # v0.5.0-alpha.1 Release Notes -Target date: 2026-08-12 +Target date: 2026-06-25 Status: published as a GitHub prerelease from the `v0.5.0-alpha.1` tag targeting `e680adf0a3ccc72559d1c4710e81010738918532`. diff --git a/docs/releases/v0.6.0-alpha.1.md b/docs/releases/v0.6.0-alpha.1.md index d05ac7b..3f2a0ed 100644 --- a/docs/releases/v0.6.0-alpha.1.md +++ b/docs/releases/v0.6.0-alpha.1.md @@ -1,6 +1,6 @@ # v0.6.0-alpha.1 Release Notes -Target date: 2026-08-26 +Target date: 2026-06-26 Status: published as a GitHub prerelease from the `v0.6.0-alpha.1` tag targeting `ec1ce8a5186801caa3c627c6714cedff7fdf43bc`. diff --git a/docs/releases/v0.7.0-alpha.1.md b/docs/releases/v0.7.0-alpha.1.md index 4a52d46..57671d5 100644 --- a/docs/releases/v0.7.0-alpha.1.md +++ b/docs/releases/v0.7.0-alpha.1.md @@ -1,6 +1,6 @@ # v0.7.0-alpha.1 Release Notes -Target date: 2026-09-09 +Target date: 2026-06-27 Status: published GitHub prerelease notes for `v0.7.0-alpha.1`, with post-publication evidence recorded on `main`. diff --git a/docs/releases/v0.8.0-alpha.1.md b/docs/releases/v0.8.0-alpha.1.md index a8fd272..12f77bd 100644 --- a/docs/releases/v0.8.0-alpha.1.md +++ b/docs/releases/v0.8.0-alpha.1.md @@ -1,6 +1,6 @@ # v0.8.0-alpha.1 Release Notes -Target date: 2026-09-23 +Target date: 2026-06-28 Status: published GitHub prerelease notes for `v0.8.0-alpha.1`, with post-publication evidence recorded on `main`. diff --git a/docs/releases/v0.9.0-alpha.1.md b/docs/releases/v0.9.0-alpha.1.md index d762a30..fa295fc 100644 --- a/docs/releases/v0.9.0-alpha.1.md +++ b/docs/releases/v0.9.0-alpha.1.md @@ -1,6 +1,6 @@ # v0.9.0-alpha.1 Release Notes -Target date: 2026-10-07 +Target date: 2026-06-28 Status: published GitHub prerelease notes for `v0.9.0-alpha.1`, with post-publication evidence recorded on `main`. diff --git a/docs/topics/release-process/README.md b/docs/topics/release-process/README.md index f5bb357..44bdcab 100644 --- a/docs/topics/release-process/README.md +++ b/docs/topics/release-process/README.md @@ -55,10 +55,37 @@ captured in [`policy.toml`](./policy.toml). [RELEASE-REQ-009] `cargo xtask release-prep ` scaffolds the mechanical release-prep surfaces that must move together: workspace package versions, lockfile package versions, a dated changelog section, release policy boundary block, release -notes stub, release boundary test stub, changelog date guard entry, and paired -release-process test-plan rows. The command does not write the release thesis, -replace topic-shelf audits, or decide scope/non-goals; reviewers must replace -the scaffold placeholders before release. [RELEASE-REQ-024] +notes stub, and paired release-process test-plan rows. The command does not +write the release thesis, replace topic-shelf audits, decide scope/non-goals, or +generate Rust test stubs; reviewers must replace the scaffold placeholders +before release. [RELEASE-REQ-024] + +`cargo xtask release-dates` reconciles the dates recorded in the release policy, +`CHANGELOG.md`, and `docs/releases/*.md` against the git tags that published +them. Those three surfaces are written from one field at scaffold time, so +comparing them against each other only proves the copy succeeded; the tags are +the independent authority for when a release happened. The check runs inside +`cargo xtask verify` and reports uncovered surfaces separately from date drift. +A clone without tags fails rather than passing silently, because a missing +independent authority is not approval. [RELEASE-REQ-008] + +For a published release, `target_date` in [`policy.toml`](./policy.toml) and +`Target date:` in `docs/releases/*.md` record the date the release was tagged, +not a date it was planned for and not the GitHub Release publication timestamp. +`v0.4.0-alpha.1` shows the difference: tagged 2026-06-24, published 2026-06-25. +The field keeps its scaffold-era name because the reconciliation parses that +exact literal. [RELEASE-REQ-008] + +Tag dates are read in UTC. `%(taggerdate)` renders in the reading machine's +timezone by default, so a tag created near midnight resolves to different days +for different operators. `v0.4.0-alpha.1` is such a tag: 2026-06-24 in PDT and +2026-06-25 in UTC. The recorded date is the UTC one, so a local run and a CI run +agree. [RELEASE-REQ-008] + +Release tags must be annotated. A lightweight tag has no tagger date, so the +check would fall back to the tagged commit's committer date and a tag placed on +an older commit would report a date that never corresponded to a release. The +reconciliation rejects lightweight `v*` tags instead. [RELEASE-REQ-008] Every release-prep branch must audit `docs/topics/` coverage and accuracy before the release-prep pull request opens. Coverage is audited topic shelves divided diff --git a/docs/topics/release-process/policy.toml b/docs/topics/release-process/policy.toml index b03dd22..e40b4f4 100644 --- a/docs/topics/release-process/policy.toml +++ b/docs/topics/release-process/policy.toml @@ -44,8 +44,6 @@ scaffold_outputs = [ "dated_changelog_section", "release_policy_boundary_block", "release_notes_stub", - "release_boundary_test_stub", - "alpha_changelog_date_guard_entry", "release_process_test_plan_rows", ] steps = [ @@ -146,7 +144,7 @@ required_evidence = [ [release_notes.v0_2_0_alpha_1] tag = "v0.2.0-alpha.1" -target_date = "2026-07-01" +target_date = "2026-06-21" status = "published" release_issue = 28 scope = [ @@ -166,7 +164,7 @@ non_goals = [ [release_notes.v0_3_0_alpha_1] tag = "v0.3.0-alpha.1" -target_date = "2026-07-15" +target_date = "2026-06-24" status = "published" release_issue = 35 scope = [ @@ -191,7 +189,7 @@ non_goals = [ [release_notes.v0_4_0_alpha_1] tag = "v0.4.0-alpha.1" -target_date = "2026-07-29" +target_date = "2026-06-25" status = "published" release_issue = 39 scope = [ @@ -213,7 +211,7 @@ non_goals = [ [release_notes.v0_5_0_alpha_1] tag = "v0.5.0-alpha.1" -target_date = "2026-08-12" +target_date = "2026-06-25" status = "published" release_issue = 42 scope = [ @@ -240,7 +238,7 @@ non_goals = [ [release_notes.v0_6_0_alpha_1] tag = "v0.6.0-alpha.1" -target_date = "2026-08-26" +target_date = "2026-06-26" status = "published" release_issue = 50 scope = [ @@ -271,7 +269,7 @@ non_goals = [ [release_notes.v0_7_0_alpha_1] tag = "v0.7.0-alpha.1" -target_date = "2026-09-09" +target_date = "2026-06-27" status = "published" release_issue = 59 published_at = "2026-06-27T22:31:49Z" @@ -316,7 +314,7 @@ non_goals = [ [release_notes.v0_8_0_alpha_1] tag = "v0.8.0-alpha.1" -target_date = "2026-09-23" +target_date = "2026-06-28" status = "published" release_issue = 62 published_at = "2026-06-28T01:41:16Z" @@ -365,7 +363,7 @@ non_goals = [ [release_notes.v0_9_0_alpha_1] tag = "v0.9.0-alpha.1" -target_date = "2026-10-07" +target_date = "2026-06-28" status = "published" release_issue = 70 published_at = "2026-06-28T07:04:06Z" @@ -407,7 +405,7 @@ non_goals = [ [release_notes.v0_10_0_alpha_1] tag = "v0.10.0-alpha.1" -target_date = "2026-10-21" +target_date = "2026-06-29" status = "published" release_issue = 76 published_at = "2026-06-29T04:21:11Z" @@ -446,7 +444,7 @@ non_goals = [ [release_notes.v0_11_0_alpha_1] tag = "v0.11.0-alpha.1" -target_date = "2026-11-04" +target_date = "2026-06-30" status = "published" release_issue = 109 published_at = "2026-06-30T07:58:29Z" diff --git a/docs/topics/release-process/test-plan.md b/docs/topics/release-process/test-plan.md index 79e64c9..7adf0e8 100644 --- a/docs/topics/release-process/test-plan.md +++ b/docs/topics/release-process/test-plan.md @@ -38,24 +38,25 @@ Out of scope: | RELEASE-REQ-004 | implemented | SemVer prerelease tags publish as GitHub prereleases. | .github/workflows/release.yml | | RELEASE-REQ-005 | implemented | The current release workflow does not publish crates or other package artifacts. | .github/workflows/release.yml | | RELEASE-REQ-006 | implemented | Pushed release tags are durable; recovery must not move, delete, or recreate release tags. | docs/topics/release-process/policy.toml | -| RELEASE-REQ-007 | implemented | Structured release policy captures the `v0.2.0-alpha.1` Core schema scope and explicit non-goals. | docs/topics/release-process/policy.toml | -| RELEASE-REQ-008 | implemented | Scheduled alpha release changelog dates match their structured release policy target dates. | CHANGELOG.md, docs/topics/release-process/policy.toml | +| RELEASE-REQ-007 | policy | Structured release policy captures the `v0.2.0-alpha.1` Core schema scope and explicit non-goals. | docs/topics/release-process/policy.toml | +| RELEASE-REQ-008 | implemented | Release dates recorded in the structured release policy, `CHANGELOG.md`, and release notes are reconciled against the annotated git tags that published them, rather than against each other. Missing tags, lightweight tags, and absent date-bearing surfaces fail; only allowlisted pre-policy omissions and the pre-publication `prep` window are advisory. The reconciliation runs in required CI. | CHANGELOG.md, docs/topics/release-process/policy.toml, xtask/src/release_dates.rs, .github/workflows/ci.yml | | RELEASE-REQ-009 | implemented | Release preparation follows a documented runbook with branch prep, local verification, PR merge gate, tag publication, workflow watch, evidence capture, and non-mutating recovery phases. | docs/topics/release-process/runbook.md, docs/topics/release-process/policy.toml | -| RELEASE-REQ-010 | implemented | Structured release policy captures the `v0.3.0-alpha.1` compiler-spine, canonical encoder, reviewed golden fixture, exact digest, and explicit non-goal boundaries. | docs/topics/release-process/policy.toml | -| RELEASE-REQ-011 | implemented | Structured release policy captures the `v0.4.0-alpha.1` target-profile, lowerability, contract-bundle validation, and explicit non-goal boundaries. | docs/topics/release-process/policy.toml | +| RELEASE-REQ-010 | policy | Structured release policy captures the `v0.3.0-alpha.1` compiler-spine, canonical encoder, reviewed golden fixture, exact digest, and explicit non-goal boundaries. | docs/topics/release-process/policy.toml | +| RELEASE-REQ-011 | policy | Structured release policy captures the `v0.4.0-alpha.1` target-profile, lowerability, contract-bundle validation, and explicit non-goal boundaries. | docs/topics/release-process/policy.toml | | RELEASE-REQ-012 | implemented | Successful `main` CI on a merged `release/vX.Y.Z-alpha.N-prep` pull request creates an immutable `vX.Y.Z-alpha.N` tag and dispatches release publication. | .github/workflows/auto-release-tag.yml, docs/topics/release-process/policy.toml | | RELEASE-REQ-013 | implemented | Release publication closes the matching GitHub milestone only after the release exists and the milestone has zero open issues. | .github/workflows/release.yml, docs/topics/release-process/policy.toml | -| RELEASE-REQ-014 | implemented | Structured release policy captures the `v0.5.0-alpha.1` Gate C admission-boundary scope, release automation, and explicit Continuum-owned non-goal boundaries. | docs/topics/release-process/policy.toml | +| RELEASE-REQ-014 | policy | Structured release policy captures the `v0.5.0-alpha.1` Gate C admission-boundary scope, release automation, and explicit Continuum-owned non-goal boundaries. | docs/topics/release-process/policy.toml | | RELEASE-REQ-015 | implemented | Manual auto-release recovery must only tag a requested `v*` release when the provided SHA is reachable from `origin/main`, has successful `main` CI, came from exactly one merged `release/*-prep` pull request, and derives the requested tag. | .github/workflows/auto-release-tag.yml, docs/topics/release-process/policy.toml | | RELEASE-REQ-016 | implemented | Release preparation must audit `docs/topics/` coverage and accuracy, and releases are blocked unless both metrics are at least 90%. | docs/topics/release-process/policy.toml, docs/topics/release-process/runbook.md | -| RELEASE-REQ-017 | implemented | Structured release policy captures the `v0.6.0-alpha.1` developer-tooling scope, supported editor integration boundary, topic-shelf audit, and explicit non-goal boundaries. | docs/topics/release-process/policy.toml | +| RELEASE-REQ-017 | policy | Structured release policy captures the `v0.6.0-alpha.1` developer-tooling scope, supported editor integration boundary, topic-shelf audit, and explicit non-goal boundaries. | docs/topics/release-process/policy.toml | | RELEASE-REQ-018 | implemented | Release preparation must record a release thesis, previous-tag diff reconciliation, zero-open milestone evidence before tag creation, no-crates publication evidence, and a release report with plan-versus-actual, fallout, and next-thesis sections. | docs/topics/release-process/policy.toml, docs/topics/release-process/runbook.md | -| RELEASE-REQ-019 | implemented | Structured release policy captures the `v0.7.0-alpha.1` file-backed authority-facts scope, governance-design boundary, policy hardening, review fallback, and explicit non-goal boundaries. | docs/topics/release-process/policy.toml | -| RELEASE-REQ-020 | implemented | Structured release policy captures the `v0.8.0-alpha.1` minimal effectful compiler-spine scope, Core effect-node boundary, unsupported-form rejection boundary, pure-golden stability boundary, and explicit non-goal boundaries. | docs/topics/release-process/policy.toml | -| RELEASE-REQ-021 | implemented | Structured release policy captures the `v0.9.0-alpha.1` first Target IR scope, Echo and git-warp target artifact boundaries, lowerability bridge, stable failure boundary, and explicit non-goal boundaries. | docs/topics/release-process/policy.toml | -| RELEASE-REQ-022 | implemented | Structured release policy captures the `v0.10.0-alpha.1` first public CLI scope, JSONL check workflow, stream record schemas, stable diagnostic kind codes, golden fixture corpus, and explicit non-goal boundaries. | docs/topics/release-process/policy.toml | -| RELEASE-REQ-023 | implemented | Structured release policy captures the `v0.11.0-alpha.1` contract-bundle assembly and canonical Target IR artifact freeze scope, checked digest/byte goldens, computed bundle integration, and explicit non-goal boundaries. | docs/topics/release-process/policy.toml | -| RELEASE-REQ-024 | implemented | `cargo xtask release-prep ` scaffolds the mechanical release-prep files that have drifted before: workspace package versions, lockfile package versions, a dated changelog section, the release policy boundary block, release notes stub, boundary test stub, changelog date guard entry, and paired release-process test-plan rows. | xtask/src/release_prep.rs | +| RELEASE-REQ-019 | policy | Structured release policy captures the `v0.7.0-alpha.1` file-backed authority-facts scope, governance-design boundary, policy hardening, review fallback, and explicit non-goal boundaries. | docs/topics/release-process/policy.toml | +| RELEASE-REQ-020 | policy | Structured release policy captures the `v0.8.0-alpha.1` minimal effectful compiler-spine scope, Core effect-node boundary, unsupported-form rejection boundary, pure-golden stability boundary, and explicit non-goal boundaries. | docs/topics/release-process/policy.toml | +| RELEASE-REQ-021 | policy | Structured release policy captures the `v0.9.0-alpha.1` first Target IR scope, Echo and git-warp target artifact boundaries, lowerability bridge, stable failure boundary, and explicit non-goal boundaries. | docs/topics/release-process/policy.toml | +| RELEASE-REQ-022 | policy | Structured release policy captures the `v0.10.0-alpha.1` first public CLI scope, JSONL check workflow, stream record schemas, stable diagnostic kind codes, golden fixture corpus, and explicit non-goal boundaries. | docs/topics/release-process/policy.toml | +| RELEASE-REQ-023 | policy | Structured release policy captures the `v0.11.0-alpha.1` contract-bundle assembly and canonical Target IR artifact freeze scope, checked digest/byte goldens, computed bundle integration, and explicit non-goal boundaries. | docs/topics/release-process/policy.toml | +| RELEASE-REQ-024 | implemented | `cargo xtask release-prep ` scaffolds the mechanical release-prep files that have drifted before: workspace package versions, lockfile package versions, a changelog section dated `--date` or today's UTC date, the release policy boundary block, release notes stub, and paired release-process test-plan rows. It does not scaffold Rust test stubs and does not extrapolate the date from release history. | xtask/src/release_prep.rs | +| RELEASE-REQ-025 | implemented | Every `[release_notes.*]` policy block is structurally complete and uniquely keyed, with per-block field lookup rather than whole-file substring matching. | docs/topics/release-process/policy.toml, xtask/src/release_dates.rs | ## Fixtures @@ -73,7 +74,7 @@ Out of scope: | docs/releases/v0.9.0-alpha.1.md | Published release notes for the first Target IR alpha. | The release workflow looked up this file by full tag name after the release-prep PR merged. | | docs/releases/v0.11.0-alpha.1.md | Published notes for the contract-bundle assembly and canonical Target IR artifact freeze alpha. | The release workflow looked up this file by full tag name after the release-prep PR merged. | | .github/workflows/auto-release-tag.yml | Successful main-CI release-prep merges create immutable release tags and dispatch publication. | The workflow derives tags only from merged `release/*-prep` branches and refuses tag mutation. | -| CHANGELOG.md | Release history for published and release-prep alpha releases. | Scheduled alpha release sections use the matching release target date. | +| CHANGELOG.md | Release history for published and release-prep alpha releases. | Published alpha release sections use the creation date of the annotated git tag that published them, checked by `cargo xtask release-dates`. | | docs/topics/release-process/policy.toml | Structured release-tag, runbook, and alpha boundary policy. | Tag mutation is forbidden, runbook phases are named, and release scope/non-goals are structured. | | docs/topics/release-process/runbook.md | Operator steps for preparing, tagging, publishing, and recovering releases. | The structured policy names the phases and checks the runbook must cover. | | `cargo xtask release-prep ` | Mechanical scaffold for the next release-prep branch. | The xtask regression exercises the writer against a temp repo skeleton and checks every generated file surface. | @@ -84,23 +85,28 @@ Out of scope: | --- | --- | --- | --- | --- | --- | --- | --- | | RELEASE-TP-001 | implemented | Golden path | RELEASE-REQ-001, RELEASE-REQ-002, RELEASE-REQ-003, RELEASE-REQ-004, RELEASE-REQ-005 | The workflow contains the tag trigger, main reachability guard, full-tag release-notes path, verified GitHub Release creation, prerelease flag, and no package publish command. | release_workflow_publishes_only_main_reachable_tags | docs/releases/v0.1.0-alpha.1.md | Static workflow contract regression. | | RELEASE-TP-002 | implemented | Policy guard | RELEASE-REQ-006 | Structured policy forbids tag mutation and names existing-valid-tag publication as recovery. | release_tag_recovery_policy_is_structured | docs/topics/release-process/policy.toml | Policy evidence is structured, not prose. | -| RELEASE-TP-003 | implemented | Boundary guard | RELEASE-REQ-007 | Structured policy captures the v0.2 Core schema scope and non-goals for lowering, encoder, bytes, digests, targets, and admission. | release_policy_tracks_v0_2_boundary | docs/topics/release-process/policy.toml | Prevents the release metadata from overclaiming the Core milestone. | -| RELEASE-TP-004 | implemented | Consistency guard | RELEASE-REQ-008 | Scheduled alpha changelog section dates equal their target dates in structured release policy. | alpha_changelog_dates_match_release_policy | CHANGELOG.md, docs/topics/release-process/policy.toml | Prevents release chronology drift across release prep and publication. | +| RELEASE-TP-003 | policy | Boundary guard | RELEASE-REQ-007 | Review confirms structured policy captures the v0.2 Core schema scope and non-goals for lowering, encoder, bytes, digests, targets, and admission. | - | docs/topics/release-process/policy.toml | Prevents the release metadata from overclaiming the Core milestone. Frozen historical record; reviewed, not string-tested. Block structure is covered by `release_policy_blocks_are_structurally_complete`. | +| RELEASE-TP-004 | implemented | Consistency guard | RELEASE-REQ-008 | Reconciliation reports drift when a release's policy, changelog, and release-notes dates agree with each other but contradict the git tag, and reports uncovered surfaces separately from drift. | release_date_reconciliation_reports_internally_consistent_wrong_dates, release_date_reconciliation_accepts_dates_matching_their_tags | CHANGELOG.md, docs/topics/release-process/policy.toml | The previous guard compared changelog against policy, which release-prep writes from one field, so both drifted together undetected. | | RELEASE-TP-005 | implemented | Runbook guard | RELEASE-REQ-009 | Structured policy names the release-prep phases and required checks for local verification, PR checks, and release existence. | release_runbook_policy_is_structured | docs/topics/release-process/policy.toml, docs/topics/release-process/runbook.md | Keeps the human runbook tied to a stable release contract. | -| RELEASE-TP-006 | implemented | Boundary guard | RELEASE-REQ-010 | Structured policy captures the v0.3 compiler-spine, canonical encoder, reviewed golden fixture, exact digest, target-lowering, and admission boundaries. | release_policy_tracks_v0_3_boundary | docs/topics/release-process/policy.toml | Prevents the release metadata from overclaiming the compiler-spine milestone. | -| RELEASE-TP-007 | implemented | Boundary guard | RELEASE-REQ-011 | Structured policy captures the v0.4 target-profile, lowerability, contract-bundle validation, target-lowering, admission, and publication boundaries. | release_policy_tracks_v0_4_boundary | docs/topics/release-process/policy.toml | Prevents the release metadata from overclaiming the target-profile and lowerability milestone. | +| RELEASE-TP-006 | policy | Boundary guard | RELEASE-REQ-010 | Review confirms structured policy captures the v0.3 compiler-spine, canonical encoder, reviewed golden fixture, exact digest, target-lowering, and admission boundaries. | - | docs/topics/release-process/policy.toml | Prevents the release metadata from overclaiming the compiler-spine milestone. Frozen historical record; reviewed, not string-tested. Block structure is covered by `release_policy_blocks_are_structurally_complete`. | +| RELEASE-TP-007 | policy | Boundary guard | RELEASE-REQ-011 | Review confirms structured policy captures the v0.4 target-profile, lowerability, contract-bundle validation, target-lowering, admission, and publication boundaries. | - | docs/topics/release-process/policy.toml | Prevents the release metadata from overclaiming the target-profile and lowerability milestone. Frozen historical record; reviewed, not string-tested. Block structure is covered by `release_policy_blocks_are_structurally_complete`. | | RELEASE-TP-008 | implemented | Automation guard | RELEASE-REQ-012, RELEASE-REQ-013 | The auto-release workflow watches successful `main` CI, derives tags from merged release-prep PRs, refuses tag moves, dispatches release publication, and the Release workflow closes zero-open milestones after publication. | release_automation_policy_is_structured, auto_release_tag_workflow_is_guarded, release_workflow_supports_dispatch_and_milestone_closure | .github/workflows/auto-release-tag.yml, .github/workflows/release.yml, docs/topics/release-process/policy.toml | Keeps release automation deterministic and non-mutating. | -| RELEASE-TP-009 | implemented | Boundary guard | RELEASE-REQ-014 | Structured policy captures the v0.5 admission-boundary scope and explicit non-goals for Continuum-owned policy, identity, delegation, revocation, ledger persistence, signature verification, target lowering, and crates.io publication. | release_policy_tracks_v0_5_boundary | docs/topics/release-process/policy.toml | Prevents the release metadata from overclaiming the Gate C admission milestone. | +| RELEASE-TP-009 | policy | Boundary guard | RELEASE-REQ-014 | Review confirms structured policy captures the v0.5 admission-boundary scope and explicit non-goals for Continuum-owned policy, identity, delegation, revocation, ledger persistence, signature verification, target lowering, and crates.io publication. | - | docs/topics/release-process/policy.toml | Prevents the release metadata from overclaiming the Gate C admission milestone. Frozen historical record; reviewed, not string-tested. Block structure is covered by `release_policy_blocks_are_structurally_complete`. | | RELEASE-TP-010 | implemented | Recovery guard | RELEASE-REQ-015 | The manual auto-release recovery path requires a successful main-CI release-prep merge, derives the tag from the merged release-prep PR, and rejects mismatched operator tag input before writing release outputs. | auto_release_tag_manual_dispatch_checks_verified_main_sha, auto_release_tag_workflow_is_guarded | .github/workflows/auto-release-tag.yml, docs/topics/release-process/policy.toml | Keeps manual recovery idempotent without allowing arbitrary tag/SHA pairing. | | RELEASE-TP-011 | implemented | Audit guard | RELEASE-REQ-016 | Structured policy defines the `docs/topics/` coverage and accuracy formulas, requires issue-or-PR evidence before merge, records release-blocking evidence fields, requires stale current-truth claims to be corrected or removed before counting as accurate, and sets both floors to at least 90%. | release_topic_audit_policy_sets_minimums | docs/topics/release-process/policy.toml | Keeps release preparation from shipping stale or under-reviewed topic shelves. | -| RELEASE-TP-012 | implemented | Boundary guard | RELEASE-REQ-017 | Structured policy captures the v0.6 developer-tooling scope, supported VS Code/Cursor integration, topic-shelf audit, and explicit non-goals for compiler CLI, language-server diagnostics, marketplace publication, target lowering, and admission tooling. | release_policy_tracks_v0_6_boundary | docs/topics/release-process/policy.toml | Prevents the release metadata from overclaiming the developer-tooling milestone. | +| RELEASE-TP-012 | policy | Boundary guard | RELEASE-REQ-017 | Review confirms structured policy captures the v0.6 developer-tooling scope, supported VS Code/Cursor integration, topic-shelf audit, and explicit non-goals for compiler CLI, language-server diagnostics, marketplace publication, target lowering, and admission tooling. | - | docs/topics/release-process/policy.toml | Prevents the release metadata from overclaiming the developer-tooling milestone. Frozen historical record; reviewed, not string-tested. Block structure is covered by `release_policy_blocks_are_structurally_complete`. | | RELEASE-TP-013 | implemented | Runbook guard | RELEASE-REQ-018 | Structured policy requires release thesis, previous-tag diff reconciliation, milestone-zero evidence at tag time, no-crates verification, release-report sections, and next-release thesis evidence. | release_runbook_policy_is_structured | docs/topics/release-process/policy.toml | Makes release claim integrity durable before and after publication. | -| RELEASE-TP-014 | implemented | Boundary guard | RELEASE-REQ-019 | Structured policy captures the v0.7 file-backed authority-facts scope, first compiler fact classes, governance design note, policy hardening, review fallback, and explicit non-goals for trusted authorship, full manifests, broader fact corpora, target IR, admission execution, and crates.io publication. | release_policy_tracks_v0_7_boundary | docs/topics/release-process/policy.toml | Prevents the release metadata from overclaiming the authority-facts milestone. | -| RELEASE-TP-015 | implemented | Boundary guard | RELEASE-REQ-020 | Structured policy captures the v0.8 minimal effectful compiler-spine scope, Core effect-node model, file-backed fact dependency, unsupported-form rejection boundary, pure Core golden stability, and explicit non-goals for target IR, runtime execution, CLI, admission, governance, and crates.io publication. | release_policy_tracks_v0_8_boundary | docs/topics/release-process/policy.toml | Prevents the release metadata from overclaiming the effectful compiler-spine milestone. | -| RELEASE-TP-016 | implemented | Boundary guard | RELEASE-REQ-021 | Structured policy captures the v0.9 first Target IR scope, Echo and git-warp review-artifact boundary, lowerability bridge, stable target-lowering failure boundary, and explicit non-goals for runtime execution, canonical target bytes, bundles, admission, v2 adapters, CLI, and crates.io publication. | release_policy_tracks_v0_9_boundary | docs/topics/release-process/policy.toml | Prevents the release metadata from overclaiming the first Target IR milestone. | -| RELEASE-TP-017 | implemented | Boundary guard | RELEASE-REQ-022 | Structured policy captures the v0.10 first public CLI scope, JSONL check workflow, deterministic input expansion, stream record schemas, stable diagnostic kind codes, golden fixture corpus, and explicit non-goals for compile/lower/explain/bundle/admission commands, human-pretty output, embedded schema validation, language server, marketplace packaging, participant policy, and crates.io publication. | release_policy_tracks_v0_10_boundary | docs/topics/release-process/policy.toml | Prevents the release metadata from overclaiming the first public CLI milestone. | -| RELEASE-TP-018 | implemented | Boundary guard | RELEASE-REQ-023 | Structured policy captures the v0.11 contract-bundle assembly and canonical Target IR artifact freeze scope, including semantic/release bundle digest goldens, Target IR byte/digest goldens, computed bundle integration, and explicit non-goals for runtime execution, admission execution, participant policy logic, Echo verifier completeness, git-warp commit creation, git-warp CRDT reducer verification, general target plugin dispatch, additional target profiles, extra source-to-target fixtures, canonical bundle-manifest bytes, and crates.io publication. | release_policy_tracks_v0_11_boundary | docs/topics/release-process/policy.toml | Prevents the release metadata from overclaiming the v0.11 cryptographic freeze. | -| RELEASE-TP-019 | implemented | Scaffolding guard | RELEASE-REQ-024 | Given a temp repo skeleton with the current release-process surfaces, `cargo xtask release-prep ` writes the version bump, lockfile package versions, dated changelog section, release policy boundary block, release notes stub, boundary test stub, changelog date guard entry, and paired planned release-process rows deterministically. | release_prep_scaffolds_version_policy_changelog_and_test_stub | xtask/src/release_prep.rs, xtask/src/tests.rs | Keeps release-prep setup mechanical so review focuses on release thesis, scope, non-goals, and evidence rather than missed boilerplate. | +| RELEASE-TP-014 | policy | Boundary guard | RELEASE-REQ-019 | Review confirms structured policy captures the v0.7 file-backed authority-facts scope, first compiler fact classes, governance design note, policy hardening, review fallback, and explicit non-goals for trusted authorship, full manifests, broader fact corpora, target IR, admission execution, and crates.io publication. | - | docs/topics/release-process/policy.toml | Prevents the release metadata from overclaiming the authority-facts milestone. Frozen historical record; reviewed, not string-tested. Block structure is covered by `release_policy_blocks_are_structurally_complete`. | +| RELEASE-TP-015 | policy | Boundary guard | RELEASE-REQ-020 | Review confirms structured policy captures the v0.8 minimal effectful compiler-spine scope, Core effect-node model, file-backed fact dependency, unsupported-form rejection boundary, pure Core golden stability, and explicit non-goals for target IR, runtime execution, CLI, admission, governance, and crates.io publication. | - | docs/topics/release-process/policy.toml | Prevents the release metadata from overclaiming the effectful compiler-spine milestone. Frozen historical record; reviewed, not string-tested. Block structure is covered by `release_policy_blocks_are_structurally_complete`. | +| RELEASE-TP-016 | policy | Boundary guard | RELEASE-REQ-021 | Review confirms structured policy captures the v0.9 first Target IR scope, Echo and git-warp review-artifact boundary, lowerability bridge, stable target-lowering failure boundary, and explicit non-goals for runtime execution, canonical target bytes, bundles, admission, v2 adapters, CLI, and crates.io publication. | - | docs/topics/release-process/policy.toml | Prevents the release metadata from overclaiming the first Target IR milestone. Frozen historical record; reviewed, not string-tested. Block structure is covered by `release_policy_blocks_are_structurally_complete`. | +| RELEASE-TP-017 | policy | Boundary guard | RELEASE-REQ-022 | Review confirms structured policy captures the v0.10 first public CLI scope, JSONL check workflow, deterministic input expansion, stream record schemas, stable diagnostic kind codes, golden fixture corpus, and explicit non-goals for compile/lower/explain/bundle/admission commands, human-pretty output, embedded schema validation, language server, marketplace packaging, participant policy, and crates.io publication. | - | docs/topics/release-process/policy.toml | Prevents the release metadata from overclaiming the first public CLI milestone. Frozen historical record; reviewed, not string-tested. Block structure is covered by `release_policy_blocks_are_structurally_complete`. | +| RELEASE-TP-018 | policy | Boundary guard | RELEASE-REQ-023 | Review confirms structured policy captures the v0.11 contract-bundle assembly and canonical Target IR artifact freeze scope, including semantic/release bundle digest goldens, Target IR byte/digest goldens, computed bundle integration, and explicit non-goals for runtime execution, admission execution, participant policy logic, Echo verifier completeness, git-warp commit creation, git-warp CRDT reducer verification, general target plugin dispatch, additional target profiles, extra source-to-target fixtures, canonical bundle-manifest bytes, and crates.io publication. | - | docs/topics/release-process/policy.toml | Prevents the release metadata from overclaiming the v0.11 cryptographic freeze. Frozen historical record; reviewed, not string-tested. Block structure is covered by `release_policy_blocks_are_structurally_complete`. | +| RELEASE-TP-019 | implemented | Scaffolding guard | RELEASE-REQ-024 | Given a temp repo skeleton with the current release-process surfaces, `cargo xtask release-prep ` writes the version bump, lockfile package versions, dated changelog section, release policy boundary block, release notes stub, and paired planned release-process rows deterministically, and writes no Rust test stub. | release_prep_scaffolds_version_policy_changelog_and_notes | xtask/src/release_prep.rs, xtask/src/tests.rs | Keeps release-prep setup mechanical so review focuses on release thesis, scope, non-goals, and evidence rather than missed boilerplate. | +| RELEASE-TP-020 | implemented | Consistency guard | RELEASE-REQ-025 | Every `[release_notes.*]` block parses with a unique section and tag, an ISO `target_date`, a known status, and `scope`/`non_goals` lists, and published blocks retain no scaffold placeholders. | release_policy_blocks_are_structurally_complete | docs/topics/release-process/policy.toml | Replaces eleven near-duplicate per-release guards with one data-driven check. | +| RELEASE-TP-022 | implemented | Boundary guard | RELEASE-REQ-008 | A lightweight release tag fails reconciliation, because it has no tagger date and would otherwise report the tagged commit's committer date. | release_date_reconciliation_rejects_lightweight_release_tags | docs/topics/release-process/policy.toml | Tag placement on an older commit must not silently redate a release. | +| RELEASE-TP-023 | implemented | Boundary guard | RELEASE-REQ-008 | A tag whose policy block still reads `prep` reports an advisory gap rather than failing, covering the window before the post-publication change flips the status. | release_date_reconciliation_tolerates_prep_status_for_a_fresh_tag | docs/topics/release-process/policy.toml | Keeps `verify` green on `main` for unrelated branches between tagging and the evidence change. | +| RELEASE-TP-024 | implemented | Boundary guard | RELEASE-REQ-008 | Deleting a covered release's changelog heading, policy block, or release-notes file fails the gate, while the pre-policy `v0.1.0-alpha.1` omission stays advisory. | release_date_reconciliation_fails_when_a_covered_surface_disappears, release_date_reconciliation_allowlists_the_prepolicy_release | docs/topics/release-process/policy.toml | Removing the evidence must not be a way to make the check pass. | +| RELEASE-TP-021 | implemented | Boundary guard | RELEASE-REQ-025 | Block-scoped parsing reports a release's own `target_date` even when a different release in the same file carries the expected date string. | release_policy_block_parsing_scopes_fields_to_their_own_release | docs/topics/release-process/policy.toml | Regression guard: whole-file substring matching passed a wrong per-release date whenever any other release shared the expected string, which is normal for releases tagged the same day. | ## Determinism Obligations @@ -125,3 +131,12 @@ Out of scope: - The current checker proves workflow contract structure, not GitHub API availability. - No crates.io policy exists; package publication remains intentionally absent. +- The scaffolded release date is `--date` or today's UTC date. It is still a + prediction: a release-prep branch that sits unmerged past its scaffold date + records a date earlier than its eventual tag, and `release-dates` only detects + that once the tag exists. Flipping the block to `published` is what binds the + recorded date to the tag. +- Per-release scope and non-goal content is reviewed rather than string-tested. + `release_policy_blocks_are_structurally_complete` proves each block is present + and complete, but nothing mechanically checks that a block's declared scope + matches what the release actually shipped. diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 0355c31..725f6f3 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -7,6 +7,7 @@ mod lawpack_goldens; mod provider_components; mod provider_contract_pack; mod provider_dependencies; +mod release_dates; mod release_prep; mod util; @@ -27,6 +28,7 @@ use lawpack_goldens::{lawpack_goldens, LawpackGoldenMode}; use provider_components::{provider_component_fixtures, ProviderComponentFixtureMode}; use provider_contract_pack::{provider_contract_pack, ProviderContractPackMode}; use provider_dependencies::provider_runtime_dependencies; +use release_dates::release_dates; use release_prep::release_prep; use util::{diff_check_base, repo_root, run_cmd, run_cmd_slice}; @@ -93,15 +95,7 @@ fn run() -> Result<(), String> { target_ir_goldens(&repo_root()?, mode) } Some("lawpack-goldens") => run_lawpack_goldens(&mut args), - Some("release-prep") => { - let version = args - .next() - .ok_or_else(|| "usage: cargo xtask release-prep ".to_owned())?; - if let Some(extra) = args.next() { - return Err(format!("unexpected release-prep argument `{extra}`")); - } - release_prep(&repo_root()?, &version) - } + Some("release-prep") => run_release_prep(&mut args), Some("provider-component-fixtures") => { let mode = match args.next().as_deref() { Some("--write") => ProviderComponentFixtureMode::Write, @@ -128,15 +122,44 @@ fn run() -> Result<(), String> { } provider_runtime_dependencies(&repo_root()?) } + Some("release-dates") => run_release_dates(&mut args), Some("verify") => verify(&repo_root()?), Some(cmd) => Err(format!("unknown xtask command `{cmd}`")), None => Err( - "usage: cargo xtask " + "usage: cargo xtask " .into(), ), } } +fn run_release_prep(args: &mut impl Iterator) -> Result<(), String> { + const USAGE: &str = "usage: cargo xtask release-prep [--date YYYY-MM-DD]"; + let version = args.next().ok_or_else(|| USAGE.to_owned())?; + let date = match args.next().as_deref() { + None => None, + Some("--date") => Some( + args.next() + .ok_or_else(|| "release-prep `--date` requires YYYY-MM-DD".to_owned())?, + ), + Some(flag) => return Err(format!("unknown release-prep flag `{flag}`; {USAGE}")), + }; + if let Some(extra) = args.next() { + return Err(format!("unexpected release-prep argument `{extra}`")); + } + release_prep(&repo_root()?, &version, date.as_deref()) +} + +fn run_release_dates(args: &mut impl Iterator) -> Result<(), String> { + match args.next().as_deref() { + Some("--check") | None => {} + Some(flag) => return Err(format!("unknown release-dates flag `{flag}`")), + } + if let Some(extra) = args.next() { + return Err(format!("unexpected release-dates argument `{extra}`")); + } + release_dates(&repo_root()?) +} + fn run_authority_facts_goldens(args: &mut impl Iterator) -> Result<(), String> { let mode = match args.next().as_deref() { Some("--write") => AuthorityFactsGoldenMode::Write, @@ -210,6 +233,7 @@ fn verify(root: &Path) -> Result<(), String> { provider_contract_pack(root, ProviderContractPackMode::Check)?; provider_runtime_dependencies(root)?; contract_check(root)?; + release_dates(root)?; let base = diff_check_base(root)?; run_cmd(root, "git", ["diff", "--check", &format!("{base}...HEAD")])?; Ok(()) diff --git a/xtask/src/release_dates.rs b/xtask/src/release_dates.rs new file mode 100644 index 0000000..a6e0365 --- /dev/null +++ b/xtask/src/release_dates.rs @@ -0,0 +1,408 @@ +//! Reconcile recorded release dates against the git tags that published them. +//! +//! The structured release policy, `CHANGELOG.md`, and `docs/releases/*.md` all +//! record a date per release. Those three surfaces are written by +//! `cargo xtask release-prep` from a single field, so comparing them against +//! each other only proves the copy succeeded. This check compares them against +//! `git for-each-ref refs/tags/v*`, which is the independent authority for when +//! a release was tagged. +//! +//! The recorded date is the tag date, not the GitHub Release publication +//! timestamp. Those can differ: `v0.4.0-alpha.1` was tagged 2026-06-24 while the +//! prerelease was published 2026-06-25. Release notes record the publication +//! timestamp separately. + +use std::collections::BTreeMap; +use std::path::Path; +use std::process::Command; + +use crate::util::read_to_string; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum Surface { + PolicyBlock, + PolicyTargetDate, + PolicyScope, + ChangelogSection, + ReleaseNotesFile, + ReleaseNotesDate, +} + +/// Surfaces that legitimately do not exist for a given tag. +/// +/// Only releases that predate a surface belong here. Every other absent surface +/// is a regression, so deleting a date-bearing surface fails the gate instead of +/// silently downgrading to an advisory line. +const LEGACY_UNCOVERED: &[(&str, Surface)] = &[ + // The first release predates the structured release policy entirely. + ("v0.1.0-alpha.1", Surface::PolicyBlock), +]; + +/// Route an absent surface to the failing or advisory bucket. +/// +/// Taking both vectors as arguments avoids a closure that would hold them +/// mutably borrowed across the rest of the comparison. +fn record_absent( + tag: &str, + surface: Surface, + message: String, + drift: &mut Vec, + gaps: &mut Vec, +) { + if is_legacy_uncovered(tag, surface) { + gaps.push(message); + } else { + drift.push(message); + } +} + +fn is_legacy_uncovered(tag: &str, surface: Surface) -> bool { + LEGACY_UNCOVERED + .iter() + .any(|(legacy_tag, legacy_surface)| *legacy_tag == tag && *legacy_surface == surface) +} + +/// One `[release_notes.*]` block parsed from the structured release policy. +/// +/// Parsing into blocks keeps every field lookup scoped to the release that owns +/// it. A whole-file substring search cannot distinguish one release's +/// `target_date` from another's, which matters because releases tagged on the +/// same day legitimately share a date string. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct ReleasePolicyBlock { + pub(crate) section: String, + pub(crate) tag: Option, + pub(crate) target_date: Option, + pub(crate) status: Option, + pub(crate) body: String, +} + +pub(crate) fn parse_release_policy_blocks(policy: &str) -> Vec { + let mut blocks = Vec::new(); + let mut current: Option = None; + for line in policy.lines() { + let trimmed = line.trim(); + if trimmed.starts_with('[') { + if let Some(block) = current.take() { + blocks.push(block); + } + if let Some(section) = trimmed + .strip_prefix("[release_notes.") + .and_then(|rest| rest.strip_suffix(']')) + { + current = Some(ReleasePolicyBlock { + section: section.to_owned(), + tag: None, + target_date: None, + status: None, + body: String::new(), + }); + } + continue; + } + if let Some(block) = current.as_mut() { + block.body.push_str(line); + block.body.push('\n'); + if let Some(value) = quoted_field(trimmed, "tag") { + block.tag = Some(value); + } else if let Some(value) = quoted_field(trimmed, "target_date") { + block.target_date = Some(value); + } else if let Some(value) = quoted_field(trimmed, "status") { + block.status = Some(value); + } + } + } + if let Some(block) = current.take() { + blocks.push(block); + } + blocks +} + +fn quoted_field(line: &str, name: &str) -> Option { + let rest = line.strip_prefix(name)?.trim_start(); + let rest = rest.strip_prefix('=')?.trim_start(); + let rest = rest.strip_prefix('"')?; + let end = rest.find('"')?; + Some(rest[..end].to_owned()) +} + +/// The `[release_notes.*]` key a tag must own: `v0.9.0-alpha.1` maps to +/// `v0_9_0_alpha_1`. +pub(crate) fn policy_section_key(tag: &str) -> String { + tag.replace(['.', '-'], "_") +} + +pub(crate) fn changelog_release_date(changelog: &str, tag: &str) -> Option { + let needle = format!("## [{tag}] - "); + changelog.lines().find_map(|line| { + line.trim_end() + .strip_prefix(&needle) + .map(|date| date.trim().to_owned()) + }) +} + +pub(crate) fn release_notes_date(notes: &str) -> Option { + notes.lines().find_map(|line| { + line.trim_end() + .strip_prefix("Target date: ") + .map(|date| date.trim().to_owned()) + }) +} + +/// A release tag and the date it was created. +/// +/// `annotated` distinguishes a tag object, whose `taggerdate` records when the +/// tag was made, from a lightweight tag, which has no tagger and would fall back +/// to the tagged commit's committer date. That fallback would let a tag placed +/// on an older commit report a date that never corresponded to a release, so +/// lightweight release tags are rejected rather than trusted. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct TagRecord { + pub(crate) date: Option, + pub(crate) annotated: bool, +} + +fn git_tag_dates(root: &Path) -> Result, String> { + let output = Command::new("git") + .args([ + "for-each-ref", + // `format-local` renders in the TZ set below, so the rendered day + // does not shift with an operator's local timezone. + "--format=%(refname:short)\t%(objecttype)\t%(taggerdate:format-local:%Y-%m-%d)", + // Release tags only; scratch tags would otherwise report permanent + // uncovered lines on every run. + "refs/tags/v*", + ]) + .env("TZ", "UTC") + .current_dir(root) + .output() + .map_err(|err| format!("failed to run `git for-each-ref`: {err}"))?; + if !output.status.success() { + return Err(format!( + "`git for-each-ref refs/tags/v*` failed: {}", + String::from_utf8_lossy(&output.stderr).trim() + )); + } + let text = String::from_utf8(output.stdout) + .map_err(|err| format!("`git for-each-ref` output is not utf-8: {err}"))?; + let mut tags = BTreeMap::new(); + for line in text.lines() { + let mut fields = line.split('\t'); + let (Some(tag), Some(object_type)) = (fields.next(), fields.next()) else { + continue; + }; + let tag = tag.trim(); + if tag.is_empty() { + continue; + } + let date = fields.next().map(str::trim).filter(|date| !date.is_empty()); + tags.insert( + tag.to_owned(), + TagRecord { + date: date.map(ToOwned::to_owned), + annotated: object_type.trim() == "tag", + }, + ); + } + Ok(tags) +} + +/// Outcome of comparing recorded dates against tag dates. +/// +/// `drift` fails the gate: a recorded date contradicts its tag, or a surface +/// that should exist is absent. `gaps` are advisory and cover only the two cases +/// that are not contradictions: surfaces that predate a release, and the window +/// between tag creation and the post-publication change that flips a block from +/// `prep` to `published`. +#[derive(Debug, Default, Clone, PartialEq, Eq)] +pub(crate) struct ReleaseDateReport { + pub(crate) drift: Vec, + pub(crate) gaps: Vec, +} + +/// Compare one tag against the policy block that should own it. +/// +/// Split out of `reconcile_release_dates` so each comparison stays readable. +fn reconcile_policy_block( + tag: &str, + tag_date: &str, + blocks: &[ReleasePolicyBlock], + drift: &mut Vec, + gaps: &mut Vec, +) { + match blocks + .iter() + .find(|block| block.tag.as_deref() == Some(tag)) + { + Some(block) => { + let section = &block.section; + let expected_section = policy_section_key(tag); + if *section != expected_section { + drift.push(format!( + "policy.toml [release_notes.{section}] declares tag {tag}, which belongs in [release_notes.{expected_section}]" + )); + } + match block.target_date.as_deref() { + Some(date) if date == tag_date => {} + Some(date) => drift.push(format!( + "policy.toml [release_notes.{section}] target_date is {date}, but tag {tag} was created {tag_date}" + )), + None => record_absent( + tag, + Surface::PolicyTargetDate, + format!( + "policy.toml [release_notes.{section}] has no target_date for tag {tag}" + ), + drift, + gaps, + ), + } + match block.status.as_deref() { + Some("published") => {} + // The tag is created before the post-publication evidence + // change flips the status, so `prep` is a lagging surface + // rather than a contradiction. Failing here would make + // `verify` red on `main` for every unrelated branch until + // that second change lands. + Some("prep") => gaps.push(format!( + "policy.toml [release_notes.{section}] still has status `prep` for existing tag {tag}" + )), + other => { + let status = other.unwrap_or(""); + drift.push(format!( + "policy.toml [release_notes.{section}] has status `{status}`, but tag {tag} exists" + )); + } + } + for field in ["scope = [", "non_goals = ["] { + if !block.body.contains(field) { + record_absent( + tag, + Surface::PolicyScope, + format!( + "policy.toml [release_notes.{section}] is missing `{field}` for tag {tag}" + ), + drift, + gaps, + ); + } + } + } + None => record_absent( + tag, + Surface::PolicyBlock, + format!("policy.toml has no [release_notes.*] block for tag {tag}"), + drift, + gaps, + ), + } +} + +/// Pure comparison over already-read inputs. +/// +/// `release_notes` maps a tag to the date parsed from its release notes; an +/// absent key means the notes file itself is missing, and a `None` value means +/// the file exists without a `Target date:` line. +pub(crate) fn reconcile_release_dates( + tags: &BTreeMap, + policy: &str, + changelog: &str, + release_notes: &BTreeMap>, +) -> ReleaseDateReport { + let blocks = parse_release_policy_blocks(policy); + let mut drift = Vec::new(); + let mut gaps = Vec::new(); + + for (tag, record) in tags { + if !record.annotated { + drift.push(format!( + "release tag {tag} is lightweight; release tags must be annotated so the tag date records the release" + )); + continue; + } + let Some(tag_date) = record.date.as_deref() else { + drift.push(format!("release tag {tag} has no tagger date")); + continue; + }; + + reconcile_policy_block(tag, tag_date, &blocks, &mut drift, &mut gaps); + + match changelog_release_date(changelog, tag) { + Some(date) if date == tag_date => {} + Some(date) => drift.push(format!( + "CHANGELOG.md `## [{tag}]` is dated {date}, but the tag was created {tag_date}" + )), + None => record_absent( + tag, + Surface::ChangelogSection, + format!("CHANGELOG.md has no `## [{tag}]` section"), + &mut drift, + &mut gaps, + ), + } + + match release_notes.get(tag) { + Some(Some(date)) if date == tag_date => {} + Some(Some(date)) => drift.push(format!( + "docs/releases/{tag}.md records `Target date: {date}`, but the tag was created {tag_date}" + )), + Some(None) => record_absent( + tag, + Surface::ReleaseNotesDate, + format!("docs/releases/{tag}.md has no `Target date:` line"), + &mut drift, + &mut gaps, + ), + None => record_absent( + tag, + Surface::ReleaseNotesFile, + format!("docs/releases/{tag}.md is missing"), + &mut drift, + &mut gaps, + ), + } + } + + ReleaseDateReport { drift, gaps } +} + +pub(crate) fn release_dates(root: &Path) -> Result<(), String> { + let tags = git_tag_dates(root)?; + if tags.is_empty() { + // A missing tag authority is not approval. Fail closed rather than + // reporting a vacuous pass in a clone that never fetched tags. + return Err( + "no `v*` release tags are present, so recorded dates cannot be reconciled; fetch tags with `git fetch --tags` before running this check" + .to_owned(), + ); + } + + let policy = read_to_string(&root.join("docs/topics/release-process/policy.toml"))?; + let changelog = read_to_string(&root.join("CHANGELOG.md"))?; + let mut release_notes = BTreeMap::new(); + for tag in tags.keys() { + let notes_path = root.join(format!("docs/releases/{tag}.md")); + if notes_path.is_file() { + release_notes.insert( + tag.clone(), + release_notes_date(&read_to_string(¬es_path)?), + ); + } + } + + let report = reconcile_release_dates(&tags, &policy, &changelog, &release_notes); + for gap in &report.gaps { + println!("release-dates: uncovered - {gap}"); + } + if report.drift.is_empty() { + let (count, gap_count) = (tags.len(), report.gaps.len()); + println!( + "release-dates: {count} tag(s) reconciled against git, {gap_count} uncovered surface(s)" + ); + return Ok(()); + } + Err(format!( + "release dates disagree with git tags:\n {}", + report.drift.join("\n ") + )) +} diff --git a/xtask/src/release_prep.rs b/xtask/src/release_prep.rs index ebcfcea..6563435 100644 --- a/xtask/src/release_prep.rs +++ b/xtask/src/release_prep.rs @@ -1,6 +1,7 @@ use std::collections::BTreeSet; use std::fs; use std::path::{Path, PathBuf}; +use std::time::{SystemTime, UNIX_EPOCH}; use crate::util::read_to_string; @@ -9,7 +10,6 @@ struct ReleasePrepVersion { tag: String, package_version: String, policy_key: String, - boundary_test_name: String, } impl ReleasePrepVersion { @@ -29,16 +29,10 @@ impl ReleasePrepVersion { } let alpha = parse_decimal(alpha, "alpha")?; let policy_key = format!("v{major}_{minor}_{patch}_alpha_{alpha}"); - let boundary_key = if patch == 0 && alpha == 1 { - format!("v{major}_{minor}") - } else { - policy_key.clone() - }; Ok(Self { tag: input.to_owned(), package_version: package_version.to_owned(), policy_key, - boundary_test_name: format!("release_policy_tracks_{boundary_key}_boundary"), }) } @@ -60,7 +54,7 @@ fn parse_decimal(part: &str, name: &str) -> Result { .map_err(|err| format!("parse release-prep {name} version part `{part}`: {err}")) } -pub(crate) fn release_prep(root: &Path, input: &str) -> Result<(), String> { +pub(crate) fn release_prep(root: &Path, input: &str, date: Option<&str>) -> Result<(), String> { let version = ReleasePrepVersion::parse(input)?; let cli_manifest_path = root.join("crates/edict-cli/Cargo.toml"); let syntax_manifest_path = root.join("crates/edict-syntax/Cargo.toml"); @@ -68,7 +62,6 @@ pub(crate) fn release_prep(root: &Path, input: &str) -> Result<(), String> { let changelog_path = root.join("CHANGELOG.md"); let policy_path = root.join("docs/topics/release-process/policy.toml"); let test_plan_path = root.join("docs/topics/release-process/test-plan.md"); - let xtask_tests_path = root.join("xtask/src/tests.rs"); let release_notes_path = release_notes_path(root, &version); if release_notes_path.exists() { @@ -79,7 +72,7 @@ pub(crate) fn release_prep(root: &Path, input: &str) -> Result<(), String> { } let policy = read_to_string(&policy_path)?; - let target_date = next_release_target_date(&policy)?; + let target_date = scaffold_release_date(date)?; let cli_manifest = replace_first_version_line( &read_to_string(&cli_manifest_path)?, &version.package_version, @@ -98,11 +91,6 @@ pub(crate) fn release_prep(root: &Path, input: &str) -> Result<(), String> { let policy = append_release_policy_block(&policy, &version, &target_date)?; let release_notes = render_release_notes_stub(&version, &target_date); let test_plan = insert_release_test_plan_rows(&read_to_string(&test_plan_path)?, &version)?; - let xtask_tests = insert_release_boundary_test_stub( - &read_to_string(&xtask_tests_path)?, - &version, - &target_date, - )?; write_file(&cli_manifest_path, &cli_manifest)?; write_file(&syntax_manifest_path, &syntax_manifest)?; @@ -111,7 +99,6 @@ pub(crate) fn release_prep(root: &Path, input: &str) -> Result<(), String> { write_file(&policy_path, &policy)?; write_file(&release_notes_path, &release_notes)?; write_file(&test_plan_path, &test_plan)?; - write_file(&xtask_tests_path, &xtask_tests)?; println!( "release-prep: scaffolded {} with target date {}", @@ -300,7 +287,7 @@ fn insert_release_test_plan_rows( test_plan: &str, version: &ReleasePrepVersion, ) -> Result { - if test_plan.contains(&version.tag) || test_plan.contains(&version.boundary_test_name) { + if test_plan.contains(&version.tag) { return Err(format!( "release-process test plan already contains {}", version.tag @@ -330,99 +317,13 @@ fn insert_release_test_plan_rows( &with_fixture, "\n## Determinism Obligations\n", &format!( - "| {tp_id} | planned | Boundary guard | {req_id} | Structured policy captures the `{tag}` scope and non-goal boundary after scaffold placeholders are replaced. | {test_name} | docs/topics/release-process/policy.toml, docs/releases/{tag}.md | Scaffolded by `cargo xtask release-prep {tag}`; reviewers must replace TODO scope/non-goal values before release. |\n", + "| {tp_id} | planned | Boundary guard | {req_id} | Review confirms structured policy captures the `{tag}` scope and non-goal boundary after scaffold placeholders are replaced. | - | docs/topics/release-process/policy.toml, docs/releases/{tag}.md | Scaffolded by `cargo xtask release-prep {tag}`; reviewers must replace TODO scope/non-goal values before release. Block structure is checked by `release_policy_blocks_are_structurally_complete`; dates are reconciled against git tags by `cargo xtask release-dates`. |\n", tag = version.tag, - test_name = version.boundary_test_name, ), "release-process test case table", ) } -fn insert_release_boundary_test_stub( - source: &str, - version: &ReleasePrepVersion, - target_date: &str, -) -> Result { - if source.contains(&format!("fn {}()", version.boundary_test_name)) { - return Err(format!( - "xtask source already contains {}", - version.boundary_test_name - )); - } - let date_case = format!(" (\"{}\", \"{target_date}\"),\n", version.tag); - let with_date = insert_in_alpha_changelog_date_cases(source, &date_case)?; - let alpha_marker = - if with_date.contains(" #[test]\n fn alpha_changelog_dates_match_release_policy()") { - " #[test]\n fn alpha_changelog_dates_match_release_policy()" - } else { - "fn alpha_changelog_dates_match_release_policy()" - }; - insert_before_once( - &with_date, - alpha_marker, - &format!( - "\ - #[test] - fn {test_name}() {{ - let root = repo_root().expect(\"repo root\"); - let policy = fs::read_to_string(root.join(\"docs/topics/release-process/policy.toml\")) - .expect(\"release policy\"); - let release_policy = toml_section(&policy, \"{section}\"); - for required in [ - \"{section}\", - \"tag = \\\"{tag}\\\"\", - \"target_date = \\\"{target_date}\\\"\", - \"status = \\\"prep\\\"\", - \"scope = [\", - \"non_goals = [\", - ] {{ - assert!( - release_policy.contains(required), - \"{tag} release policy missing structured field: {{required}}\" - ); - }} - for forbidden in [ - \"TODO_release_scope\", - \"TODO_release_non_goal\", - ] {{ - assert!( - !release_policy.contains(forbidden), - \"{tag} release policy must replace scaffold placeholder: {{forbidden}}\" - ); - }} - }} - -", - test_name = version.boundary_test_name, - section = version.policy_section(), - tag = version.tag, - ), - "xtask alpha changelog date guard", - ) -} - -fn insert_in_alpha_changelog_date_cases(source: &str, date_case: &str) -> Result { - if source.contains(date_case.trim()) { - return Err("alpha changelog date guard already contains release".into()); - } - let function_start = source - .find("fn alpha_changelog_dates_match_release_policy()") - .ok_or_else(|| { - "xtask source missing alpha_changelog_dates_match_release_policy".to_owned() - })?; - let tail = &source[function_start..]; - let list_end = tail - .find("] {") - .ok_or_else(|| "alpha changelog date guard missing tuple list terminator".to_owned())?; - let line_start = tail[..list_end].rfind('\n').map_or(0, |index| index + 1); - let insert_at = function_start + line_start; - let mut output = String::with_capacity(source.len() + date_case.len()); - output.push_str(&source[..insert_at]); - output.push_str(date_case); - output.push_str(&source[insert_at..]); - Ok(output) -} - fn next_numbered_id(text: &str, prefix: &str) -> Result { let mut max_id = None; let mut width = 3usize; @@ -470,40 +371,67 @@ fn replace_once( Ok(text.replacen(needle, replacement, 1)) } -fn next_release_target_date(policy: &str) -> Result { - let latest = policy - .lines() - .filter_map(|line| { - line.trim() - .strip_prefix("target_date = ") - .and_then(parse_quoted_string) - }) - .next_back() - .ok_or_else(|| "release policy contains no target_date fields".to_owned())?; - add_days_to_iso_date(&latest, 14) +/// The date a release-prep scaffold records. +/// +/// This used to be the last `target_date` plus fourteen days, from an era when +/// the field held a planned date on a biweekly cadence. Now that `target_date` +/// records the date a release was actually tagged, extrapolating from history +/// produces a date in the past: seeded from the realigned record, the next value +/// would be 2026-07-14. An explicit `--date` wins; otherwise today's UTC date is +/// used, so the scaffold is at least current when it is written. +fn scaffold_release_date(explicit: Option<&str>) -> Result { + match explicit { + Some(date) => { + validate_iso_date(date)?; + Ok(date.to_owned()) + } + None => today_utc(), + } } -fn add_days_to_iso_date(date: &str, days: u8) -> Result { +fn validate_iso_date(date: &str) -> Result<(), String> { let mut parts = date.split('-'); - let mut year = parse_date_part(parts.next(), "year")?; - let mut month = parse_date_part(parts.next(), "month")?; - let mut day = parse_date_part(parts.next(), "day")?; - if parts.next().is_some() || month == 0 || month > 12 || day == 0 { + let year = parse_date_part(parts.next(), "year")?; + let month = parse_date_part(parts.next(), "month")?; + let day = parse_date_part(parts.next(), "day")?; + if parts.next().is_some() || date.len() != 10 || month == 0 || month > 12 || day == 0 { return Err(format!("invalid ISO date `{date}`")); } - for _ in 0..days { - day += 1; - let month_days = days_in_month(year, month)?; - if day > month_days { - day = 1; - month += 1; - if month > 12 { - month = 1; - year += 1; - } - } + if day > days_in_month(year, month)? { + return Err(format!("invalid ISO date `{date}`")); } - Ok(format!("{year:04}-{month:02}-{day:02}")) + Ok(()) +} + +fn today_utc() -> Result { + let seconds = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(|err| format!("read system clock: {err}"))? + .as_secs(); + let days = i64::try_from(seconds / 86_400) + .map_err(|err| format!("system clock out of range: {err}"))?; + Ok(iso_date_from_days_since_epoch(days)) +} + +/// Civil date from a day count since 1970-01-01, using Howard Hinnant's +/// `civil_from_days`. Avoids taking a date-library dependency for one call. +fn iso_date_from_days_since_epoch(days: i64) -> String { + let z = days + 719_468; + let era = if z >= 0 { z } else { z - 146_096 } / 146_097; + let day_of_era = z - era * 146_097; + let year_of_era = + (day_of_era - day_of_era / 1460 + day_of_era / 36_524 - day_of_era / 146_096) / 365; + let year = year_of_era + era * 400; + let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100); + let month_position = (5 * day_of_year + 2) / 153; + let day = day_of_year - (153 * month_position + 2) / 5 + 1; + let month = if month_position < 10 { + month_position + 3 + } else { + month_position - 9 + }; + let year = if month <= 2 { year + 1 } else { year }; + format!("{year:04}-{month:02}-{day:02}") } fn parse_date_part(part: Option<&str>, name: &str) -> Result { diff --git a/xtask/src/tests.rs b/xtask/src/tests.rs index f7efca2..eaffa36 100644 --- a/xtask/src/tests.rs +++ b/xtask/src/tests.rs @@ -1,4 +1,4 @@ -use std::collections::BTreeSet; +use std::collections::{BTreeMap, BTreeSet}; use std::fs; use std::path::{Path, PathBuf}; use std::process::Command; @@ -1701,8 +1701,6 @@ fn release_runbook_policy_is_structured() { "dated_changelog_section", "release_policy_boundary_block", "release_notes_stub", - "release_boundary_test_stub", - "alpha_changelog_date_guard_entry", "release_process_test_plan_rows", ] { assert!( @@ -1713,10 +1711,12 @@ fn release_runbook_policy_is_structured() { } #[test] -fn release_prep_scaffolds_version_policy_changelog_and_test_stub() { +fn release_prep_scaffolds_version_policy_changelog_and_notes() { let root = temp_root("release-prep-scaffold"); write_release_prep_scaffold_fixture(&root); - release_prep(&root, "v0.12.0-alpha.1").expect("release prep scaffold"); + let xtask_source_before = + fs::read_to_string(root.join("xtask/src/tests.rs")).expect("xtask tests before"); + release_prep(&root, "v0.12.0-alpha.1", Some("2026-08-04")).expect("release prep scaffold"); let package_version = "version = \"0.12.0-alpha.1\""; assert!( @@ -1740,7 +1740,7 @@ fn release_prep_scaffolds_version_policy_changelog_and_test_stub() { let changelog = fs::read_to_string(root.join("CHANGELOG.md")).expect("changelog"); assert!( changelog.contains( - "## [Unreleased]\n\n## [v0.12.0-alpha.1] - 2026-11-18\n\n### Changed\n\n- Pending release note." + "## [Unreleased]\n\n## [v0.12.0-alpha.1] - 2026-08-04\n\n### Changed\n\n- Pending release note." ), "release prep must move pending changelog content under the dated release section" ); @@ -1749,7 +1749,7 @@ fn release_prep_scaffolds_version_policy_changelog_and_test_stub() { for required in [ "[release_notes.v0_12_0_alpha_1]", "tag = \"v0.12.0-alpha.1\"", - "target_date = \"2026-11-18\"", + "target_date = \"2026-08-04\"", "status = \"prep\"", "TODO_release_scope", "TODO_release_non_goal", @@ -1768,21 +1768,19 @@ fn release_prep_scaffolds_version_policy_changelog_and_test_stub() { let test_plan = fs::read_to_string(root.join("docs/topics/release-process/test-plan.md")) .expect("test plan"); assert!( - test_plan.contains("RELEASE-REQ-025") - && test_plan.contains("RELEASE-TP-020") - && test_plan.contains("release_policy_tracks_v0_12_boundary"), + test_plan.contains("RELEASE-REQ-025") && test_plan.contains("RELEASE-TP-020"), "release prep must add paired requirement and test-plan rows" ); + // Release prep no longer writes Rust test stubs. Per-release policy content + // is reviewed, block structure is covered by one data-driven test, and + // dates are reconciled against git tags by `cargo xtask release-dates`. + // Comparing the whole file against the seeded fixture is what makes this + // assertion able to fail: searching for a stub name the fixture never + // contained would pass no matter what release-prep wrote. let xtask_source = fs::read_to_string(root.join("xtask/src/tests.rs")).expect("xtask tests"); - assert!( - xtask_source.contains("fn release_policy_tracks_v0_12_boundary()") - && xtask_source.contains("(\"v0.12.0-alpha.1\", \"2026-11-18\")"), - "release prep must add the boundary test stub and changelog date case" - ); - assert!( - xtask_source.contains("!release_policy.contains(forbidden)") - && xtask_source.contains("release policy must replace scaffold placeholder"), - "release boundary test must fail while scaffold placeholders remain" + assert_eq!( + xtask_source, xtask_source_before, + "release prep must not modify xtask/src/tests.rs" ); } @@ -1815,7 +1813,8 @@ fn release_prep_rejects_existing_release_notes_before_writing() { }) .collect::>(); - let error = release_prep(&root, "v0.12.0-alpha.1").expect_err("existing notes reject"); + let error = release_prep(&root, "v0.12.0-alpha.1", Some("2026-08-04")) + .expect_err("existing notes reject"); assert!( error.contains("release notes already exist"), "duplicate release notes must be rejected before writes: {error}" @@ -1876,7 +1875,7 @@ fn write_release_prep_policy_fixture(root: &Path) { fn write_release_prep_test_plan_fixture(root: &Path) { fs::write( root.join("docs/topics/release-process/test-plan.md"), - "# Release Process Test Plan\n\n## Requirements\n\n| ID | Status | Requirement | Source |\n| --- | --- | --- | --- |\n| RELEASE-REQ-023 | implemented | Existing release boundary. | docs/topics/release-process/policy.toml |\n| RELEASE-REQ-024 | implemented | Release prep scaffolding exists. | xtask/src/main.rs |\n\n## Fixtures\n\n| Fixture | Purpose | Oracle |\n| --- | --- | --- |\n| docs/topics/release-process/policy.toml | Structured release policy. | Checked by xtask. |\n\n## Test Cases\n\n| ID | Status | Category | Requirement | Oracle | Evidence | Fixtures | Notes |\n| --- | --- | --- | --- | --- | --- | --- | --- |\n| RELEASE-TP-018 | implemented | Boundary guard | RELEASE-REQ-023 | Existing release boundary is pinned. | release_policy_tracks_v0_11_boundary | docs/topics/release-process/policy.toml | Existing row. |\n| RELEASE-TP-019 | implemented | Scaffolding guard | RELEASE-REQ-024 | Release prep scaffolding is pinned. | release_prep_scaffolds_version_policy_changelog_and_test_stub | xtask/src/main.rs | Existing row. |\n\n## Determinism Obligations\n", + "# Release Process Test Plan\n\n## Requirements\n\n| ID | Status | Requirement | Source |\n| --- | --- | --- | --- |\n| RELEASE-REQ-023 | implemented | Existing release boundary. | docs/topics/release-process/policy.toml |\n| RELEASE-REQ-024 | implemented | Release prep scaffolding exists. | xtask/src/main.rs |\n\n## Fixtures\n\n| Fixture | Purpose | Oracle |\n| --- | --- | --- |\n| docs/topics/release-process/policy.toml | Structured release policy. | Checked by xtask. |\n\n## Test Cases\n\n| ID | Status | Category | Requirement | Oracle | Evidence | Fixtures | Notes |\n| --- | --- | --- | --- | --- | --- | --- | --- |\n| RELEASE-TP-018 | implemented | Boundary guard | RELEASE-REQ-023 | Existing release boundary is pinned. | release_policy_tracks_v0_11_boundary | docs/topics/release-process/policy.toml | Existing row. |\n| RELEASE-TP-019 | implemented | Scaffolding guard | RELEASE-REQ-024 | Release prep scaffolding is pinned. | release_prep_scaffolds_version_policy_changelog_and_notes | xtask/src/main.rs | Existing row. |\n\n## Determinism Obligations\n", ) .expect("test plan"); } @@ -2764,412 +2763,333 @@ fn release_automation_policy_is_structured() { } #[test] -fn release_policy_tracks_v0_3_boundary() { +fn release_policy_blocks_are_structurally_complete() { let root = repo_root().expect("repo root"); let policy = fs::read_to_string(root.join("docs/topics/release-process/policy.toml")) .expect("release policy"); - for required in [ - "[release_notes.v0_3_0_alpha_1]", - "tag = \"v0.3.0-alpha.1\"", - "target_date = \"2026-07-15\"", - "status = \"published\"", - "compiler_spine", - "surface_validation_split", - "canonical_core_encoder", - "reviewed_core_golden_fixture", - "exact_core_digest_fixture", - "no_target_lowering", - "no_bundle_admission", - ] { + let blocks = crate::release_dates::parse_release_policy_blocks(&policy); + + // Exact, not a lower bound: a lower bound lets a historical block be + // deleted silently, which is the failure class this check exists to stop. + assert_eq!( + blocks.len(), + 10, + "release policy must retain exactly one block per release from v0.2.0-alpha.1 onward" + ); + + let mut seen_sections = BTreeSet::new(); + let mut seen_tags = BTreeSet::new(); + for block in &blocks { + let section = &block.section; assert!( - policy.contains(required), - "v0.3 release policy missing structured field: {required}" + seen_sections.insert(section.clone()), + "duplicate release policy section [release_notes.{section}]" ); - } -} - -#[test] -fn release_policy_tracks_v0_4_boundary() { - let root = repo_root().expect("repo root"); - let policy = fs::read_to_string(root.join("docs/topics/release-process/policy.toml")) - .expect("release policy"); - for required in [ - "[release_notes.v0_4_0_alpha_1]", - "tag = \"v0.4.0-alpha.1\"", - "target_date = \"2026-07-29\"", - "status = \"published\"", - "target_profile_conformance", - "lowerability_direct_adapter", - "contract_bundle_manifest_validation", - "no_target_lowerer_execution", - "no_admission_policy", - "no_crates_io_publish", - ] { + let Some(tag) = block.tag.as_deref() else { + panic!("[release_notes.{section}] has no tag"); + }; assert!( - policy.contains(required), - "v0.4 release policy missing structured field: {required}" + seen_tags.insert(tag.to_owned()), + "duplicate release policy tag {tag} in [release_notes.{section}]" ); - } -} - -#[test] -fn release_policy_tracks_v0_5_boundary() { - let root = repo_root().expect("repo root"); - let policy = fs::read_to_string(root.join("docs/topics/release-process/policy.toml")) - .expect("release policy"); - for required in [ - "[release_notes.v0_5_0_alpha_1]", - "tag = \"v0.5.0-alpha.1\"", - "target_date = \"2026-08-12\"", - "status = \"published\"", - "edict_owned_continuum_participation_boundary", - "gate_c_admission_request_validation", - "gate_c_admission_receipt_validation", - "admission_request_digest_binding", - "policy_epoch_receipt_binding", - "invocation_capability_evidence_validation", - "release_automation_after_main_ci", - "no_participant_policy_evaluation", - "no_participant_identity_delegation_or_revocation", - "no_admission_ledger_persistence", - "no_signature_verification", - "no_target_lowerer_execution", - "no_bundle_digest_recomputation", - "no_crates_io_publish", - ] { + assert_eq!( + *section, + crate::release_dates::policy_section_key(tag), + "[release_notes.{section}] declares tag {tag}, which belongs in a differently keyed section" + ); + let Some(date) = block.target_date.as_deref() else { + panic!("[release_notes.{section}] has no target_date"); + }; assert!( - policy.contains(required), - "v0.5 release policy missing structured field: {required}" + is_iso_date(date), + "[release_notes.{section}] target_date `{date}` is not an ISO date" ); - } -} - -#[test] -fn release_policy_tracks_v0_6_boundary() { - let root = repo_root().expect("repo root"); - let policy = fs::read_to_string(root.join("docs/topics/release-process/policy.toml")) - .expect("release policy"); - let v0_6_policy = toml_section(&policy, "[release_notes.v0_6_0_alpha_1]"); - for required in [ - "[release_notes.v0_6_0_alpha_1]", - "tag = \"v0.6.0-alpha.1\"", - "target_date = \"2026-08-26\"", - "status = \"published\"", - "editor_highlight_roles", - "tree_sitter_grammar_source", - "textmate_grammar_artifact", - "vscode_cursor_extension_package", - "topic_shelf_coverage_audit", - "no_compiler_cli", - "no_language_server", - "no_marketplace_publication", - "no_target_lowering", - "no_admission_tooling", - "no_crates_io_publish", - ] { + let Some(status) = block.status.as_deref() else { + panic!("[release_notes.{section}] has no status"); + }; assert!( - v0_6_policy.contains(required), - "v0.6 release policy missing structured field: {required}" + matches!(status, "published" | "planned" | "prep"), + "[release_notes.{section}] has unexpected status `{status}`" ); + for field in ["scope = [", "non_goals = ["] { + assert!( + block.body.contains(field), + "[release_notes.{section}] is missing `{field}`" + ); + } + // Applies to `prep` too: auto-release-tag publishes from a merged + // release-prep branch, so a placeholder that survives to merge would + // ship in the release. + for placeholder in ["TODO_release_scope", "TODO_release_non_goal"] { + assert!( + !block.body.contains(placeholder), + "[release_notes.{section}] still has scaffold placeholder `{placeholder}`" + ); + } } } +/// Regression guard for the substring-matching flaw this test file used to +/// have. The previous per-release guards asserted +/// `policy.contains("target_date = \"...\"")` against the whole file, so a +/// release carrying a wrong date still passed whenever any other release +/// happened to carry the expected string. Releases tagged on the same day +/// share a date, so that is the normal case, not a corner case. #[test] -fn release_policy_tracks_v0_7_boundary() { - let root = repo_root().expect("repo root"); - let policy = fs::read_to_string(root.join("docs/topics/release-process/policy.toml")) - .expect("release policy"); - let v0_7_policy = toml_section(&policy, "[release_notes.v0_7_0_alpha_1]"); - for required in [ - "[release_notes.v0_7_0_alpha_1]", - "tag = \"v0.7.0-alpha.1\"", - "target_date = \"2026-09-09\"", - "status = \"published\"", - "release_issue = 59", - "published_at = \"2026-06-27T22:31:49Z\"", - "release_url = \"https://github.com/flyingrobots/edict/releases/tag/v0.7.0-alpha.1\"", - "tag_object = \"f7888160f7f9a0d7b9b82d4f78bb38b886856a1e\"", - "peeled_commit = \"6f9c731b4f36d3283dcb448b14761832ab916b07\"", - "release_notes_source_commit = \"6f9c731b4f36d3283dcb448b14761832ab916b07\"", - "post_publication_evidence_pr = 61", - "main_ci_run = 28303787401", - "auto_release_tag_run = 28303801200", - "release_workflow_run = 28303809157", - "milestone_number = 8", - "milestone_closed_at = \"2026-06-27T22:31:50Z\"", - "milestone_open_issues = 0", - "release_assets = 0", - "crates_io_published = false", - "file_backed_authority_facts", - "operation_profile_facts", - "profile_write_class_allowances", - "effect_write_classes", - "budget_facts", - "lawpack_source_identity", - "target_profile_source_identity", - "deterministic_loaded_fact_harness", - "stable_load_failure_kinds", - "authority_fact_governance_design_note", - "release_policy_and_rust_standards_hardening", - "review_bot_fallback_policy", - "no_trusted_lawpack_or_target_profile_authorship", - "no_full_lawpack_manifest_loading", - "no_full_target_profile_manifest_loading", - "no_obstruction_obligation_adapter_footprint_cost_or_target_capability_corpus_loading", - "no_global_registry_trust_root_identity_system_or_revocation_model", - "no_target_ir_generation", - "no_full_effectful_source_lowering", - "no_admission_execution_workflow", - "no_crates_io_publish", - ] { - assert!( - v0_7_policy.contains(required), - "v0.7 release policy missing structured field: {required}" - ); - } +fn release_policy_block_parsing_scopes_fields_to_their_own_release() { + let policy = concat!( + "[release_notes.v0_3_0_alpha_1]\n", + "tag = \"v0.3.0-alpha.1\"\n", + "target_date = \"1999-01-01\"\n", + "status = \"published\"\n", + "scope = [\n \"first_scope\",\n]\n", + "non_goals = [\n \"first_non_goal\",\n]\n", + "\n", + "[release_notes.v0_4_0_alpha_1]\n", + "tag = \"v0.4.0-alpha.1\"\n", + "target_date = \"2026-06-24\"\n", + "status = \"published\"\n", + "scope = [\n \"second_scope\",\n]\n", + "non_goals = [\n \"second_non_goal\",\n]\n", + ); + let blocks = crate::release_dates::parse_release_policy_blocks(policy); + assert_eq!(blocks.len(), 2, "both release blocks must parse"); + + let first = blocks + .iter() + .find(|block| block.tag.as_deref() == Some("v0.3.0-alpha.1")) + .expect("v0.3 block"); + + // A whole-file substring search finds the correct-looking date here... + assert!(policy.contains("target_date = \"2026-06-24\"")); + // ...but it belongs to v0.4, and block-scoped parsing reports v0.3's own. + assert_eq!(first.target_date.as_deref(), Some("1999-01-01")); + assert_eq!(first.status.as_deref(), Some("published")); + assert!(first.body.contains("first_scope")); + assert!(!first.body.contains("second_scope")); +} + +/// The reconciliation compares recorded dates against git tag dates, which is +/// the check the previous guard could not perform: it compared `CHANGELOG.md` +/// against `policy.toml`, and release-prep writes both from one field, so the +/// two agreed even while both disagreed with the tags. +#[test] +fn release_date_reconciliation_reports_internally_consistent_wrong_dates() { + let policy = concat!( + "[release_notes.v0_9_0_alpha_1]\n", + "tag = \"v0.9.0-alpha.1\"\n", + "target_date = \"2026-10-07\"\n", + "status = \"published\"\n", + "scope = [\n \"only_scope\",\n]\n", + "non_goals = [\n \"only_non_goal\",\n]\n", + ); + let changelog = + "# Changelog\n\n## [v0.9.0-alpha.1] - 2026-10-07\n\n## [v0.8.0-alpha.1] - 2026-06-28\n"; + let tags = BTreeMap::from([ + ("v0.9.0-alpha.1".to_owned(), annotated_tag("2026-06-28")), + ("v0.8.0-alpha.1".to_owned(), annotated_tag("2026-06-28")), + ]); + let notes = BTreeMap::from([("v0.9.0-alpha.1".to_owned(), Some("2026-10-07".to_owned()))]); + + let report = crate::release_dates::reconcile_release_dates(&tags, policy, changelog, ¬es); + + // v0.9 is internally consistent across all three surfaces and still wrong, + // which is precisely what comparing copies against each other cannot see. + // v0.8 contributes two more findings: its policy block and release notes are + // absent, and absence of a covered surface is a failure, not an advisory. + assert_eq!( + report.drift.len(), + 5, + "three contradicting surfaces plus two absent ones: {:?}", + report.drift + ); + assert!(report + .drift + .iter() + .any(|entry| entry.contains("target_date is 2026-10-07") + && entry.contains("created 2026-06-28"))); + assert!(report + .drift + .iter() + .any(|entry| entry.contains("CHANGELOG.md `## [v0.9.0-alpha.1]` is dated 2026-10-07"))); + + // v0.8's date agrees with its tag, but its absent surfaces still fail: + // deleting the evidence must not be a way to pass. + assert!(report + .drift + .iter() + .any(|entry| entry.contains("no [release_notes.*] block for tag v0.8.0-alpha.1"))); + assert!(report + .drift + .iter() + .any(|entry| entry.contains("docs/releases/v0.8.0-alpha.1.md is missing"))); + assert!( + report.gaps.is_empty(), + "nothing here is allowlisted or mid-publication: {:?}", + report.gaps + ); } #[test] -fn release_policy_tracks_v0_8_boundary() { - let root = repo_root().expect("repo root"); - let policy = fs::read_to_string(root.join("docs/topics/release-process/policy.toml")) - .expect("release policy"); - let v0_8_policy = toml_section(&policy, "[release_notes.v0_8_0_alpha_1]"); - for required in [ - "[release_notes.v0_8_0_alpha_1]", - "tag = \"v0.8.0-alpha.1\"", - "target_date = \"2026-09-23\"", - "status = \"published\"", - "release_issue = 62", - "published_at = \"2026-06-28T01:41:16Z\"", - "release_url = \"https://github.com/flyingrobots/edict/releases/tag/v0.8.0-alpha.1\"", - "tag_object = \"32e843c5e5f7b9252078c2b8a99afa23daeab411\"", - "peeled_commit = \"c6a166ccea0fcb61fff9b8d76bfb5d51d613e2eb\"", - "release_notes_source_commit = \"c6a166ccea0fcb61fff9b8d76bfb5d51d613e2eb\"", - "main_ci_run = 28307840316", - "auto_release_tag_run = 28307856119", - "release_workflow_run = 28307864582", - "milestone_number = 9", - "milestone_closed_at = \"2026-06-28T01:41:16Z\"", - "milestone_open_issues = 0", - "release_assets = 0", - "crates_io_published = false", - "minimal_effectful_compiler_spine", - "core_effect_node_model", - "core_obstruction_arm_model", - "canonical_effect_node_encoding", - "file_backed_effectful_compiler_context", - "annotated_effectful_let_lowering", - "deterministic_obstruction_map_lowering", - "source_order_stable_obstruction_binders", - "unsupported_effectful_branch_yield_rejection", - "chained_effect_call_rejection", - "typed_effect_call_rejection", - "duplicate_obstruction_failure_rejection", - "pure_core_golden_stability", - "no_target_ir_generation", - "no_target_runtime_execution", - "no_adapter_composition", - "no_public_cli", - "no_admission_execution_workflow", - "no_lawpack_governance_implementation", - "no_crates_io_publish", - ] { - assert!( - v0_8_policy.contains(required), - "v0.8 release policy missing structured field: {required}" - ); - } +fn release_date_reconciliation_accepts_dates_matching_their_tags() { + let policy = concat!( + "[release_notes.v0_8_0_alpha_1]\n", + "tag = \"v0.8.0-alpha.1\"\n", + "target_date = \"2026-06-28\"\n", + "status = \"published\"\n", + "scope = [\n \"only_scope\",\n]\n", + "non_goals = [\n \"only_non_goal\",\n]\n", + ); + let changelog = "# Changelog\n\n## [v0.8.0-alpha.1] - 2026-06-28\n"; + let tags = BTreeMap::from([("v0.8.0-alpha.1".to_owned(), annotated_tag("2026-06-28"))]); + let notes = BTreeMap::from([("v0.8.0-alpha.1".to_owned(), Some("2026-06-28".to_owned()))]); + + let report = crate::release_dates::reconcile_release_dates(&tags, policy, changelog, ¬es); + assert!( + report.drift.is_empty(), + "unexpected drift: {:?}", + report.drift + ); + assert!(report.gaps.is_empty(), "unexpected gaps: {:?}", report.gaps); } +/// A lightweight tag has no tagger, so `creatordate` would silently fall back +/// to the tagged commit's committer date. Tagging an older commit would then +/// report a date that never corresponded to a release, so the tag is rejected +/// rather than trusted. #[test] -fn release_policy_tracks_v0_9_boundary() { - let root = repo_root().expect("repo root"); - let policy = fs::read_to_string(root.join("docs/topics/release-process/policy.toml")) - .expect("release policy"); - let v0_9_policy = toml_section(&policy, "[release_notes.v0_9_0_alpha_1]"); - for required in [ - "[release_notes.v0_9_0_alpha_1]", - "tag = \"v0.9.0-alpha.1\"", - "target_date = \"2026-10-07\"", - "status = \"published\"", - "release_issue = 70", - "published_at = \"2026-06-28T07:04:06Z\"", - "release_url = \"https://github.com/flyingrobots/edict/releases/tag/v0.9.0-alpha.1\"", - "tag_object = \"c6a4ea6b10d438cd407cd7f273fecf1fd012b2d3\"", - "peeled_commit = \"81bacc5a240bd3ea50af934a3611ce6b3f505043\"", - "release_notes_source_commit = \"81bacc5a240bd3ea50af934a3611ce6b3f505043\"", - "main_ci_run = 28314566818", - "auto_release_tag_run = 28314582826", - "release_workflow_run = 28314590143", - "milestone_number = 10", - "milestone_closed_at = \"2026-06-28T07:04:06Z\"", - "milestone_open_issues = 0", - "release_assets = 0", - "crates_io_published = false", - "first_target_ir_alpha", - "echo_span_ir_review_artifact", - "gitwarp_commit_reducer_ir_review_artifact", - "lowerability_to_target_ir_bridge", - "explicit_target_profile_selection", - "stable_target_lowering_failure_kinds", - "core_obligation_preservation", - "target_ir_topic_shelf", - "no_runtime_execution", - "no_echo_verifier", - "no_gitwarp_commit_creation", - "no_gitwarp_crdt_reducer_verification", - "no_general_target_plugin_dispatch", - "no_canonical_target_ir_bytes_or_digests", - "no_bundle_or_admission_generation", - "no_v2_adapter_composition", - "no_public_cli", - "no_crates_io_publish", - ] { - assert!( - v0_9_policy.contains(required), - "v0.9 release policy missing structured field: {required}" - ); - } +fn release_date_reconciliation_rejects_lightweight_release_tags() { + let policy = concat!( + "[release_notes.v0_8_0_alpha_1]\n", + "tag = \"v0.8.0-alpha.1\"\n", + "target_date = \"2026-06-28\"\n", + "status = \"published\"\n", + "scope = [\n \"only_scope\",\n]\n", + "non_goals = [\n \"only_non_goal\",\n]\n", + ); + let changelog = "# Changelog\n\n## [v0.8.0-alpha.1] - 2026-06-28\n"; + let tags = BTreeMap::from([( + "v0.8.0-alpha.1".to_owned(), + crate::release_dates::TagRecord { + date: Some("2026-06-28".to_owned()), + annotated: false, + }, + )]); + let notes = BTreeMap::from([("v0.8.0-alpha.1".to_owned(), Some("2026-06-28".to_owned()))]); + + let report = crate::release_dates::reconcile_release_dates(&tags, policy, changelog, ¬es); + assert!( + report + .drift + .iter() + .any(|entry| entry.contains("is lightweight")), + "lightweight release tags must fail: {:?}", + report.drift + ); } +/// A tag exists before the post-publication change flips its block from `prep` +/// to `published`. That window is a lagging surface, not a contradiction; +/// failing it would make `verify` red on `main` for unrelated branches. #[test] -fn release_policy_tracks_v0_10_boundary() { - let root = repo_root().expect("repo root"); - let policy = fs::read_to_string(root.join("docs/topics/release-process/policy.toml")) - .expect("release policy"); - let v0_10_policy = toml_section(&policy, "[release_notes.v0_10_0_alpha_1]"); - for required in [ - "[release_notes.v0_10_0_alpha_1]", - "tag = \"v0.10.0-alpha.1\"", - "target_date = \"2026-10-21\"", - "status = \"published\"", - "release_issue = 76", - "published_at = \"2026-06-29T04:21:11Z\"", - "release_url = \"https://github.com/flyingrobots/edict/releases/tag/v0.10.0-alpha.1\"", - "tag_object = \"11e516c8ea8be5fa6739efd545c5b8fb40cbc46d\"", - "peeled_commit = \"622834138af249e70d717d6b7a940e4b01e23f4d\"", - "release_notes_source_commit = \"622834138af249e70d717d6b7a940e4b01e23f4d\"", - "main_ci_run = 28348355987", - "auto_release_tag_run = 28348383070", - "release_workflow_run = 28348397035", - "milestone_number = 11", - "milestone_closed_at = \"2026-06-29T04:21:11Z\"", - "milestone_open_issues = 0", - "release_assets = 0", - "crates_io_published = false", - "first_public_cli_surface", - "jsonl_check_workflow", - "deterministic_input_expansion", - "compiler_settings_json_schema", - "cli_stream_record_schemas", - "structured_cli_diagnostics", - "stable_diagnostic_kind_codes", - "golden_cli_fixture_corpus", - "cli_topic_shelf", - "no_compile_lower_explain_bundle_or_admission_commands", - "no_human_pretty_output", - "no_embedded_json_schema_validation_engine", - "no_language_server", - "no_marketplace_packaging", - "no_participant_policy_execution", - "no_crates_io_publish", - ] { - assert!( - v0_10_policy.contains(required), - "v0.10 release policy missing structured field: {required}" - ); - } +fn release_date_reconciliation_tolerates_prep_status_for_a_fresh_tag() { + let policy = concat!( + "[release_notes.v0_12_0_alpha_1]\n", + "tag = \"v0.12.0-alpha.1\"\n", + "target_date = \"2026-08-04\"\n", + "status = \"prep\"\n", + "scope = [\n \"only_scope\",\n]\n", + "non_goals = [\n \"only_non_goal\",\n]\n", + ); + let changelog = "# Changelog\n\n## [v0.12.0-alpha.1] - 2026-08-04\n"; + let tags = BTreeMap::from([("v0.12.0-alpha.1".to_owned(), annotated_tag("2026-08-04"))]); + let notes = BTreeMap::from([("v0.12.0-alpha.1".to_owned(), Some("2026-08-04".to_owned()))]); + + let report = crate::release_dates::reconcile_release_dates(&tags, policy, changelog, ¬es); + assert!( + report.drift.is_empty(), + "a freshly tagged prep release must not fail the gate: {:?}", + report.drift + ); + assert!( + report + .gaps + .iter() + .any(|entry| entry.contains("still has status `prep`")), + "the lagging status must still be reported: {:?}", + report.gaps + ); } +/// Deleting a date-bearing surface must fail rather than downgrade to an +/// advisory gap, otherwise removing the evidence makes the gate pass. #[test] -fn release_policy_tracks_v0_11_boundary() { - let root = repo_root().expect("repo root"); - let policy = fs::read_to_string(root.join("docs/topics/release-process/policy.toml")) - .expect("release policy"); - let v0_11_policy = toml_section(&policy, "[release_notes.v0_11_0_alpha_1]"); - for required in [ - "[release_notes.v0_11_0_alpha_1]", - "tag = \"v0.11.0-alpha.1\"", - "target_date = \"2026-11-04\"", - "status = \"published\"", - "release_issue = 109", - "published_at = \"2026-06-30T07:58:29Z\"", - "release_url = \"https://github.com/flyingrobots/edict/releases/tag/v0.11.0-alpha.1\"", - "tag_object = \"e5d207527d737131e54a8d2614765e63ff7218e6\"", - "peeled_commit = \"3eb71f6127e31b68ea4e0bb766623930ce24ae46\"", - "release_notes_source_commit = \"3eb71f6127e31b68ea4e0bb766623930ce24ae46\"", - "main_ci_run = 28429259737", - "auto_release_tag_run = 28429294814", - "release_workflow_run = 28429313876", - "milestone_number = 12", - "milestone_closed_at = \"2026-06-30T07:58:29Z\"", - "milestone_open_issues = 0", - "release_assets = 0", - "crates_io_published = false", - "contract_bundle_assembly", - "semantic_bundle_digest_preimage", - "release_bundle_digest_preimage", - "bundle_digest_goldens", - "canonical_target_ir_value_model", - "canonical_target_ir_cbor_bytes", - "target_ir_artifact_digest_frame", - "target_ir_byte_digest_goldens", - "computed_target_ir_bundle_assembly", - "xtask_core_target_ir_bundle_golden_checks", - "no_runtime_execution", - "no_admission_execution", - "no_participant_policy_logic", - "no_verifier_completeness", - "no_echo_verifier_completeness", - "no_git_warp_commit_creation", - "no_git_warp_crdt_reducer_verification", - "no_general_target_plugin_dispatch", - "no_additional_target_profiles", - "no_extra_source_to_target_fixtures", - "no_canonical_contract_bundle_manifest_bytes", - "no_crates_io_publish", +fn release_date_reconciliation_fails_when_a_covered_surface_disappears() { + let tags = BTreeMap::from([("v0.8.0-alpha.1".to_owned(), annotated_tag("2026-06-28"))]); + let report = + crate::release_dates::reconcile_release_dates(&tags, "", "# Changelog\n", &BTreeMap::new()); + for expected in [ + "no [release_notes.*] block for tag v0.8.0-alpha.1", + "CHANGELOG.md has no `## [v0.8.0-alpha.1]` section", + "docs/releases/v0.8.0-alpha.1.md is missing", ] { assert!( - v0_11_policy.contains(required), - "v0.11 release policy missing structured field: {required}" + report.drift.iter().any(|entry| entry.contains(expected)), + "missing surface must be drift, not a gap: {expected} not in {:?}", + report.drift ); } + assert!( + report.gaps.is_empty(), + "no surface here is allowlisted: {:?}", + report.gaps + ); } +/// The one release that predates the structured policy stays advisory. #[test] -fn alpha_changelog_dates_match_release_policy() { - let root = repo_root().expect("repo root"); - let changelog = fs::read_to_string(root.join("CHANGELOG.md")).expect("changelog"); - let policy = fs::read_to_string(root.join("docs/topics/release-process/policy.toml")) - .expect("release policy"); - for (tag, target) in [ - ("v0.2.0-alpha.1", "2026-07-01"), - ("v0.3.0-alpha.1", "2026-07-15"), - ("v0.4.0-alpha.1", "2026-07-29"), - ("v0.5.0-alpha.1", "2026-08-12"), - ("v0.6.0-alpha.1", "2026-08-26"), - ("v0.7.0-alpha.1", "2026-09-09"), - ("v0.8.0-alpha.1", "2026-09-23"), - ("v0.9.0-alpha.1", "2026-10-07"), - ("v0.10.0-alpha.1", "2026-10-21"), - ("v0.11.0-alpha.1", "2026-11-04"), - ] { - assert!( - policy.contains(&format!("tag = \"{tag}\"")), - "release policy missing tag {tag}" - ); - assert!( - policy.contains(&format!("target_date = \"{target}\"")), - "release policy missing target date {target}" - ); - assert!( - changelog.contains(&format!("## [{tag}] - {target}")), - "{tag} changelog date must match release policy target date {target}" - ); +fn release_date_reconciliation_allowlists_the_prepolicy_release() { + let tags = BTreeMap::from([("v0.1.0-alpha.1".to_owned(), annotated_tag("2026-06-21"))]); + let notes = BTreeMap::from([("v0.1.0-alpha.1".to_owned(), Some("2026-06-21".to_owned()))]); + let report = crate::release_dates::reconcile_release_dates( + &tags, + "", + "# Changelog\n\n## [v0.1.0-alpha.1] - 2026-06-21\n", + ¬es, + ); + assert!( + report.drift.is_empty(), + "the pre-policy release must not fail the gate: {:?}", + report.drift + ); + assert!( + report + .gaps + .iter() + .any(|entry| entry.contains("no [release_notes.*] block for tag v0.1.0-alpha.1")), + "the allowlisted omission must still be reported: {:?}", + report.gaps + ); +} + +fn annotated_tag(date: &str) -> crate::release_dates::TagRecord { + crate::release_dates::TagRecord { + date: Some(date.to_owned()), + annotated: true, } } +fn is_iso_date(value: &str) -> bool { + let bytes = value.as_bytes(); + bytes.len() == 10 + && bytes[4] == b'-' + && bytes[7] == b'-' + && bytes + .iter() + .enumerate() + .all(|(index, byte)| index == 4 || index == 7 || byte.is_ascii_digit()) +} + fn wit_named_type(interface: &Interface, name: &str) -> TypeId { *interface .types @@ -4025,31 +3945,6 @@ fn legacy_target_profile_wit_is_not_provider_envelope() { .contains_key(&WorldKey::Name("verify".to_owned()))); } -#[test] -fn release_policy_tracks_v0_2_boundary() { - let root = repo_root().expect("repo root"); - let policy = fs::read_to_string(root.join("docs/topics/release-process/policy.toml")) - .expect("release policy"); - for required in [ - "[release_notes.v0_2_0_alpha_1]", - "tag = \"v0.2.0-alpha.1\"", - "target_date = \"2026-07-01\"", - "core_semantic_model", - "normative_core_schema", - "no_source_to_core_lowering", - "no_canonical_encoder", - "no_golden_core_bytes", - "no_exact_core_digests", - "no_target_lowering", - "no_bundle_admission", - ] { - assert!( - policy.contains(required), - "v0.2 release policy missing structured field: {required}" - ); - } -} - #[test] fn core_cddl_declares_v1_semantic_model() { let root = repo_root().expect("repo root");