From db87ccff1f0a8fadd1c0fb71757ff0402f903c6c Mon Sep 17 00:00:00 2001 From: James Ross Date: Sun, 19 Jul 2026 09:40:43 -0700 Subject: [PATCH 01/16] refactor(governance): schedule work with version milestones --- .continuum/release.yml | 16 +- .github/ISSUE_TEMPLATE/bug.yml | 10 +- .github/ISSUE_TEMPLATE/chore.yml | 7 +- .github/ISSUE_TEMPLATE/feature.yml | 9 +- .github/ISSUE_TEMPLATE/rfc.yml | 7 +- .github/pull_request_template.md | 2 +- .github/workflows/ci.yml | 23 +- .github/workflows/release-crates.yml | 27 - AGENTS.md | 14 +- CHANGELOG.md | 7 + CONTRIBUTING.md | 46 +- Cargo.lock | 1 + RELEASE.md | 50 +- docs/BEARING.md | 35 +- docs/CRATES_IO_RELEASE.md | 90 +- docs/JEDIT_CAPABILITY_EVIDENCE.md | 4 +- docs/METHOD.md | 50 +- docs/README.md | 2 +- docs/architecture/hosts.md | 3 +- .../weslaw-semantic-law-ir.md | 2 +- .../holmes-weslaw-assurance-prd-test-plan.md | 2 +- docs/design/README.md | 9 +- docs/design/TEMPLATE.md | 14 +- docs/governance/DOCUMENTATION_STANDARD.md | 44 +- docs/governance/RELEASE_CHECKLIST.md | 44 +- docs/governance/RELEASE_POLICY.md | 111 +- docs/governance/labels.md | 29 +- docs/method/guide.md | 7 +- docs/method/process.md | 26 +- docs/method/release-runbook.md | 147 +- docs/method/release.md | 177 +-- docs/site/roadmap.md | 15 +- docs/topics/README.md | 2 +- docs/topics/contributing/first-pr.md | 27 +- docs/topics/contributing/triage.md | 157 ++- docs/topics/releases.md | 77 +- scripts/smoke/repo-bats-prepush.sh | 1 + scripts/test-ci-locally.sh | 2 +- test/README.md | 1 + test/ci-workflows.bats | 35 +- test/release-governance.bats | 190 ++- xtask/Cargo.toml | 3 + xtask/src/main.rs | 1186 ++++++++++------- 43 files changed, 1715 insertions(+), 996 deletions(-) diff --git a/.continuum/release.yml b/.continuum/release.yml index 27231c17..e003e14d 100644 --- a/.continuum/release.yml +++ b/.continuum/release.yml @@ -10,9 +10,7 @@ versioning: strategy: semver tag_format: 'v{version}' release_branch_format: 'release/v{version}' - release_milestone_format: 'Release: v{version}' - release_lane_label_format: 'v{version}' - goalpost_milestone_format: 'Goalpost: {name}' + release_milestone_format: 'v{version}' version_sources: - path: crates/wesley-core/Cargo.toml @@ -90,7 +88,7 @@ docs: validation: docs_check: cargo xtask docs-check - prep: cargo xtask release-prep-guard --version {version} + post_merge_pre_tag_tracker_clear: cargo xtask release-prep-guard --version {version} preflight: cargo xtask preflight rust_advisory_audit: cargo audit release_check: cargo xtask release-check @@ -119,11 +117,11 @@ publish: issue_model: live_tracker: github - implementation_bucket: 'Goalpost: ... milestone' - release_gate_bucket: 'Release: v{version} milestone for gate and closeout issues' - release_scheduling_axis: 'v{version} label for pre-tag blockers and scheduled work' - triage_axis: 'triage:* label' - required_state_label: 'exactly one of triage:* or v{version}' + scheduling_authority: 'plain v{version} milestone' + scheduled_state: 'exactly one v{version} milestone and no triage:* or concrete version label' + unscheduled_state: 'exactly one triage:* label and no milestone' + classification_axis: 'type, status, legend:*, work:*, and pkg:* labels' + release_gate: 'final pre-tag issue in the target version milestone' release_evidence: internal_packet: docs/method/releases/v{version}/release.md diff --git a/.github/ISSUE_TEMPLATE/bug.yml b/.github/ISSUE_TEMPLATE/bug.yml index 0385ce99..95337ff2 100644 --- a/.github/ISSUE_TEMPLATE/bug.yml +++ b/.github/ISSUE_TEMPLATE/bug.yml @@ -9,17 +9,17 @@ body: - type: markdown attributes: value: | - GitHub Issues are Wesley's live Method tracker. Labels are canonical - tracker metadata; request classification here and maintainers or agents - will apply the final scheduling, legend, and work labels. + GitHub Issues are Wesley's live Method tracker. A `triage:*` label marks + unscheduled intake; a plain `vX.Y.Z` milestone schedules work. Other + labels classify the issue. - type: textarea id: requested_classification attributes: label: Requested classification - description: Optional triage request. This does not set canonical labels. + description: Optional triage request. This does not change canonical GitHub metadata. placeholder: | - Suggested scheduling state: + Suggested release milestone or triage state: Suggested legend: Suggested work type: diff --git a/.github/ISSUE_TEMPLATE/chore.yml b/.github/ISSUE_TEMPLATE/chore.yml index e3d018ad..f4faed49 100644 --- a/.github/ISSUE_TEMPLATE/chore.yml +++ b/.github/ISSUE_TEMPLATE/chore.yml @@ -10,15 +10,16 @@ body: attributes: value: | Chores still need a hill. GitHub Issues are Wesley's live Method - tracker; labels are canonical scheduling, legend, and work metadata. + tracker. A `triage:*` label marks unscheduled intake; a plain `vX.Y.Z` + milestone schedules work. Other labels classify the issue. - type: textarea id: requested_classification attributes: label: Requested classification - description: Optional triage request. This does not set canonical labels. + description: Optional triage request. This does not change canonical GitHub metadata. placeholder: | - Suggested scheduling state: + Suggested release milestone or triage state: Suggested legend: Suggested work type: diff --git a/.github/ISSUE_TEMPLATE/feature.yml b/.github/ISSUE_TEMPLATE/feature.yml index 82785dc7..807ce94a 100644 --- a/.github/ISSUE_TEMPLATE/feature.yml +++ b/.github/ISSUE_TEMPLATE/feature.yml @@ -10,16 +10,17 @@ body: attributes: value: | Frame feature work around sponsor actors, hills, and playbacks. - GitHub Issues are Wesley's live Method tracker; labels are canonical - lane, legend, and work metadata. + GitHub Issues are Wesley's live Method tracker. A `triage:*` label marks + unscheduled intake; a plain `vX.Y.Z` milestone schedules work. Other + labels classify the issue. - type: textarea id: requested_classification attributes: label: Requested classification - description: Optional triage request. This does not set canonical labels. + description: Optional triage request. This does not change canonical GitHub metadata. placeholder: | - Suggested scheduling state: + Suggested release milestone or triage state: Suggested legend: Suggested work type: diff --git a/.github/ISSUE_TEMPLATE/rfc.yml b/.github/ISSUE_TEMPLATE/rfc.yml index 0e75f901..97a2c7ef 100644 --- a/.github/ISSUE_TEMPLATE/rfc.yml +++ b/.github/ISSUE_TEMPLATE/rfc.yml @@ -11,15 +11,16 @@ body: value: | RFCs are for direction-setting changes. Frame them around the product hill, not just the mechanism. GitHub Issues are Wesley's live Method - tracker; labels are canonical scheduling, legend, and work metadata. + tracker. A `triage:*` label marks unscheduled intake; a plain `vX.Y.Z` + milestone schedules work. Other labels classify the issue. - type: textarea id: requested_classification attributes: label: Requested classification - description: Optional triage request. This does not set canonical labels. + description: Optional triage request. This does not change canonical GitHub metadata. placeholder: | - Suggested scheduling state: + Suggested release milestone or triage state: Suggested legend: Suggested work type: diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index b51ab0b6..f720143b 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -28,7 +28,7 @@ Closes # ## Tracker Hygiene - [ ] Linked issue had `work-in-progress` while active. -- [ ] Linked issue lane/status/legend labels are current. +- [ ] Linked issue is scheduled in one plain `vX.Y.Z` milestone and has no `triage:*` or version label. - [ ] Follow-up work is captured as GitHub Issues, not hidden in chat or local-only backlog files. ## Risk diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index eca679c5..dfb944b6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -42,29 +42,7 @@ jobs: --schema test/fixtures/examples/ecommerce.graphql \ --json > "$RUNNER_TEMP/ecommerce-l1.json" - - name: Detect changes for repo Bats tests (no servers) - id: changelog - env: - EVENT: ${{ github.event_name }} - BASE_REF: ${{ github.event.pull_request.base.sha }} - BEFORE: ${{ github.event.before }} - SHA: ${{ github.sha }} - run: | - set -euo pipefail - RANGE="${BEFORE}..${SHA}" - if [ "$EVENT" = "pull_request" ] && [ -n "${BASE_REF}" ]; then - RANGE="${BASE_REF}..${SHA}" - fi - echo "Diff range: $RANGE" - CHANGED=$(git diff --name-only "$RANGE" || true) - echo "$CHANGED" | sed 's/^/ - /' - NEED=false - # Trigger only on unit/serverless repository checks. - echo "$CHANGED" | grep -E -q '^(scripts/serve-static\.mjs|test/serve-static-(unit|relative-unit)\.bats|scripts/generate-ir-fixtures\.mjs|test/docs-planning-boundary\.bats|test/domain-empty-boundary\.bats|test/ir-fixtures\.bats|test/ci-)' && NEED=true || true - echo "RUN_BATS=$NEED" >> $GITHUB_ENV - - name: Repo Bats tests (unit/docs/ci checks only) - if: ${{ env.RUN_BATS == 'true' }} env: BATS_LIB_PATH: test/vendor TERM: xterm @@ -79,6 +57,7 @@ jobs: test/ir-fixtures.bats test/ci-package-manager-policy.bats test/ci-workflows.bats + test/release-governance.bats ) for f in "${files[@]}"; do echo "Running Bats: $f" diff --git a/.github/workflows/release-crates.yml b/.github/workflows/release-crates.yml index 3b00084f..8d598699 100644 --- a/.github/workflows/release-crates.yml +++ b/.github/workflows/release-crates.yml @@ -74,33 +74,6 @@ jobs: GH_TOKEN: ${{ github.token }} run: cargo xtask release-guard --tag "$GITHUB_REF_NAME" - - name: Check for open version issues - env: - GH_TOKEN: ${{ github.token }} - run: | - set -euo pipefail - version="${GITHUB_REF_NAME#v}" - issue_query() { - gh issue list "$@" \ - --state open \ - --json number,title,url \ - --jq '.[] | "#\(.number) \(.title) \(.url)"' - } - matches="$( - { - issue_query --search "\"${GITHUB_REF_NAME}\" OR \"${version}\" repo:${GITHUB_REPOSITORY}" - issue_query --milestone "${GITHUB_REF_NAME}" 2>/dev/null || true - issue_query --milestone "${version}" 2>/dev/null || true - issue_query --label "${GITHUB_REF_NAME}" 2>/dev/null || true - issue_query --label "${version}" 2>/dev/null || true - } | sort -u - )" - if [ -n "$matches" ]; then - echo "Open GitHub issues are associated with ${GITHUB_REF_NAME}:" - echo "$matches" - exit 1 - fi - - name: Docs check run: cargo xtask docs-check diff --git a/AGENTS.md b/AGENTS.md index df98ae61..b87c46a0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -34,9 +34,10 @@ Do not audit the repository by recursively walking the filesystem. Follow the au - **`docs/BEARING.md`**: Current execution gravity and active tensions. - **`docs/design/README.md`**: Active design packets and structural doctrine. -- **GitHub Issues**: The active source of truth for pending work. Use - `triage:*` labels for unscheduled intake and `vX.Y.Z` labels for work - scheduled into a named future release. See +- **GitHub Issues**: The active source of truth for pending work. Unscheduled + intake has exactly one `triage:*` label and no milestone. Work scheduled + into a named release has exactly one plain `vX.Y.Z` milestone and no + `triage:*` or concrete-version scheduling label. See **`docs/topics/contributing/triage.md`**. ### 4. The Proof @@ -50,7 +51,7 @@ When starting a new session or recovering from context loss: 1. **Read `docs/BEARING.md`** to find the current execution gravity. 2. **Read `docs/METHOD.md`** to understand the work doctrine. -3. **Check scheduled release lanes and triage queues** using +3. **Check version-milestone schedules and triage queues** using `docs/topics/contributing/triage.md`. 4. **Check `git log -n 5` and `git status`** to verify the current branch state. @@ -59,8 +60,9 @@ When starting a new session or recovering from context loss: After altering files: 1. **Verify Truth**: Ensure documentation is updated if behavior or structure changed. -2. **Log Debt**: Add follow-on work as GitHub Issues with either a `triage:*` - intake label or a concrete `vX.Y.Z` release label. +2. **Log Debt**: Add follow-on work as either unscheduled intake (exactly one + `triage:*` label and no milestone) or scheduled work (exactly one plain + `vX.Y.Z` milestone and no triage or concrete-version scheduling label). 3. **Commit**: Use focused, conventional commit messages. Propose a draft before executing. 4. **Validate**: Run `pnpm run preflight`. diff --git a/CHANGELOG.md b/CHANGELOG.md index 36964133..04d2c382 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,13 @@ The format is based on Keep a Changelog, and this project adheres to Semantic Ve ## [Unreleased] +### Changed + +- Replaced slogan goalpost milestones and duplicate version scheduling labels + with one plain `vX.Y.Z` milestone per planned release. Release guards now + require that exact open milestone and block on its open issues; the release + gate closes before the immutable tag is created. + ## [0.3.0-alpha.1] - 2026-07-15 ### Added diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 61ca3c50..0e729c83 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -21,7 +21,7 @@ For broader orientation, read these surfaces in order: - [docs/design/README.md](docs/design/README.md) for active design packets and boundary doctrine - [docs/METHOD.md](docs/METHOD.md) for the workflow contract - [docs/topics/README.md](docs/topics/README.md) for contributor and operator task topics -- [docs/topics/contributing/triage.md](docs/topics/contributing/triage.md) for issue triage and release-lane scheduling +- [docs/topics/contributing/triage.md](docs/topics/contributing/triage.md) for issue triage and version-milestone scheduling - [docs/governance/labels.md](docs/governance/labels.md) for issue and PR label semantics - [AGENTS.md](AGENTS.md) for repository-specific automation rules @@ -53,10 +53,16 @@ such as `wesley-postgres`. GitHub owns live work state: - GitHub Issues hold slices and raw intake. -- GitHub Milestones hold goalposts and release gates. -- GitHub Projects provide roadmap board views. -- GitHub labels carry triage state, release scheduling, legend, work-shape, - and ownership metadata. +- GitHub Milestones are the sole scheduling authority for named releases. + Every scheduled issue belongs to exactly one plain `vX.Y.Z` milestone. +- GitHub Projects and release outcomes provide narrative groupings and views + over scheduled work; they are not scheduling authorities. +- GitHub labels classify intake, legend, work shape, ownership, and optional + status. Labels never schedule work into a named release. + +An unscheduled issue has exactly one `triage:*` label and no milestone. A +scheduled issue has exactly one plain `vX.Y.Z` milestone and no `triage:*` +or concrete-version scheduling label. Repository files are the evidence ledger. Design packets, witnesses, retros, release notes, and signpost docs record stable truth and proof after work is @@ -72,9 +78,10 @@ backlog files: - [Near-term roadmap issue](https://github.com/flyingrobots/wesley/issues/646) - [Wesley Roadmap Project](https://github.com/users/flyingrobots/projects/18) -Starter issues must have one scheduling-state label such as `v0.3.0`, one -`Goalpost: ...` milestone, one primary file or tiny file set, and one local -validation command. If an issue still has a `triage:*` label, it is not a +Starter issues must belong to exactly one plain future-release milestone such +as `v0.3.0`, have no `triage:*` or concrete-version scheduling label, name +one primary file or tiny file set, and provide one local validation command. +If an issue still has a `triage:*` label, it is unscheduled and is not a starter task until a maintainer schedules, splits, moves, or closes it. Every PR must name at least one GitHub Issue in its body. Use a closing keyword @@ -137,18 +144,19 @@ See `docs/method/legends/` for the standing questions each legend owns. ## Default Loop -1. Pull a GitHub Issue with the right goalpost milestone and either a - `triage:*` intake label or concrete `vX.Y.Z` release label. +1. Select a GitHub Issue. Before implementation, confirm it is scheduled in + exactly one plain `vX.Y.Z` milestone and carries no `triage:*` or + concrete-version scheduling label. If it is still unscheduled, schedule, + split, move, or close it first. 2. Add `work-in-progress` while the slice is active. -3. If the issue is still under `triage:*`, schedule it into a named release, - split it, move it, or close it before implementation. -4. Write or update the design packet when the work needs durable design context. -5. Write failing tests from the playback questions or issue acceptance criteria. -6. Implement. -7. Produce a reproducible witness. -8. File follow-up work as GitHub Issues with the right goalpost and either a - `triage:*` intake label or concrete release lane. -9. Update ship surfaces such as `docs/BEARING.md`, `CHANGELOG.md`, and release +3. Write or update the design packet when the work needs durable design context. +4. Write failing tests from the playback questions or issue acceptance criteria. +5. Implement. +6. Produce a reproducible witness. +7. File follow-up work in exactly one canonical state: unscheduled with one + `triage:*` label and no milestone, or scheduled in one plain version + milestone with no triage or concrete-version scheduling label. +8. Update ship surfaces such as `docs/BEARING.md`, `CHANGELOG.md`, and release notes only from merged `main` state. Review state rides on branches and PRs. GitHub Issues, Milestones, Projects, and diff --git a/Cargo.lock b/Cargo.lock index d5a3d651..c61559ce 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1688,6 +1688,7 @@ dependencies = [ "serde_json", "tokio", "toml", + "yaml-rust2", ] [[package]] diff --git a/RELEASE.md b/RELEASE.md index 2e0e0d3c..a7dc97eb 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -32,14 +32,23 @@ Do not duplicate those facts in prose unless the profile changes too. Wesley intentionally differs from the generic Continuum template in these places: -- Implementation work stays in `Goalpost: ...` GitHub milestones. -- Version scheduling uses concrete `vX.Y.Z` labels because GitHub issues can - have only one milestone. -- `Release: vX.Y.Z` milestones hold release-gate and closeout issues only. -- Release guards query exact-version issue references and `vX.Y.Z` labels, not - release-gate milestones. -- Autotag is not enabled. Maintainers create a signed tag manually after final - release guards pass from synced `main`. +Plain `vX.Y.Z` milestones are the only release scheduling axis. Every +prerelease uses its exact full tag-form SemVer as the plain milestone name (for +example, `v0.3.0-alpha.2`), without a `Release:` prefix. + +- Every issue committed to a release belongs to exactly one plain `vX.Y.Z` + milestone and has no `triage:*` or concrete-version scheduling label. +- The same milestone contains implementation, documentation, preparation, and + the release-gate issue. +- Release outcomes are narrative groupings in release packets or Project views, + not scheduling authorities. +- The release gate is the final pre-tag issue in that milestone: move or close + every other open issue, then close the gate before tagging. +- Release guards query the exact target milestone as the sole scheduling + authority rather than relying on labels or a manually maintained manifest. +- Autotag is not enabled. Maintainers create a signed local tag manually after + the post-merge, pre-tag checks pass from synced `main`; the tag-specific guard + then passes before that tag is pushed. - Publication is tag-triggered through `.github/workflows/release-crates.yml`. - crates.io is the public package registry. npm/JSR dist-tag policy does not apply to Wesley's current release surface. @@ -49,23 +58,40 @@ places: Prepare: ```bash -cargo xtask release-prep-guard --version X.Y.Z cargo xtask preflight cargo xtask release-check cargo xtask package-crates --version X.Y.Z ``` -Tag from synced `main` only: +After the release-prep PR lands, keep the release gate open while syncing and +running the fresh pre-tag preflight. Record the exact commit that passed: ```bash +git fetch origin --tags --prune git switch main -git pull --ff-only -git fetch origin --tags +git merge --ff-only origin/main +validated_head="$(git rev-parse HEAD)" +test "$validated_head" = "$(git rev-parse origin/main)" +cargo xtask preflight + +# Only now: complete human sign-off and close the release gate. +cargo xtask release-prep-guard --version X.Y.Z + +# Refresh refs and prove HEAD is still the validated, synced commit. +git fetch origin --tags --prune +test "$(git rev-parse HEAD)" = "$validated_head" +test "$(git rev-parse HEAD)" = "$(git rev-parse origin/main)" git tag -s vX.Y.Z -m "release: vX.Y.Z" cargo xtask release-guard --tag vX.Y.Z git push origin vX.Y.Z ``` +If anything fails after gate closure, do not push again. Follow the execution +runbook to resolve remote tag state first. Only a conclusively unpublished +failure may delete the local tag and reopen the gate; a present or indeterminate +remote tag must not mutate either. Never delete, move, or recreate a tag that +reached the remote. + The tag workflow publishes the crates and GitHub Release from the immutable tag. ## Canonical Docs diff --git a/docs/BEARING.md b/docs/BEARING.md index fdbf8ff8..feff354d 100644 --- a/docs/BEARING.md +++ b/docs/BEARING.md @@ -13,20 +13,22 @@ change without a code/docs commit, it belongs in GitHub. Wesley's live work hierarchy is: -| Concept | Canonical Surface | -| ------------------ | --------------------------------------------------------------------------- | -| Goalpost | GitHub Milestone named `Goalpost: ...` | -| Slice | GitHub Issue assigned to exactly one goalpost milestone | -| Release | GitHub Milestone named `Release: vX.Y.Z` | -| Release gate | GitHub Issue assigned to the release milestone and linked to goalposts | -| Roadmap board | [Wesley Roadmap Project](https://github.com/users/flyingrobots/projects/18) | -| Lane/legend/status | GitHub Issue labels | - -GitHub permits only one milestone per issue. Implementation issues therefore -stay in goalpost milestones. Versioned release milestones hold release-gate -issues that link to the goalposts selected for that release. - -The current formal goalpost and release list is the GitHub milestone list: +| Concept | Canonical Surface | +| ------------------ | ------------------------------------------------------------------------------------------------ | +| Unscheduled intake | GitHub Issue with exactly one `triage:*` label and no milestone | +| Scheduled slice | GitHub Issue in exactly one plain `vX.Y.Z` milestone, with no triage or version scheduling label | +| Release | GitHub Milestone named exactly `vX.Y.Z` | +| Release gate | Final pre-tag GitHub Issue assigned to the same version milestone | +| Release outcome | Narrative grouping in release packets or Project views; never a scheduling authority | +| Roadmap board | [Wesley Roadmap Project](https://github.com/users/flyingrobots/projects/18) | +| Classification | GitHub labels for triage, legend, type, ownership, and optional status | + +A plain version milestone is the sole scheduling authority for named release +work. All implementation, documentation, preparation, and gate issues for a +release share that milestone. Before tagging, move or close every other open +issue in the milestone and close the gate last. + +The current formal release schedule is the GitHub milestone list: . ## Active Gravity @@ -116,8 +118,9 @@ fixtures, and comprehensive `docs/topics/` routing. Future releases must be cut from signed tags on synced `main`; do not merge post-release evidence backfills to `main` after a release boundary. -Versioned release work is tracked by `Release: ...` milestones and release-gate -issues. The release policy and checklist remain the operational source: +Versioned release work is tracked by plain `vX.Y.Z` milestones. Each release +gate is the final pre-tag issue in the same milestone. The release policy and +checklist remain the operational source: - [Release Policy](./governance/RELEASE_POLICY.md) - [Release Checklist](./governance/RELEASE_CHECKLIST.md) diff --git a/docs/CRATES_IO_RELEASE.md b/docs/CRATES_IO_RELEASE.md index 6a5c1775..3d1e0f9e 100644 --- a/docs/CRATES_IO_RELEASE.md +++ b/docs/CRATES_IO_RELEASE.md @@ -25,6 +25,13 @@ commands that this procedure must match. helper for the first alpha package set; it is not the project release path. 8. The tagged `main` commit is the repo release boundary. Do not merge manual release-truth or publication-evidence backfills to `main` after publishing. +9. One exact plain `vX.Y.Z` milestone is the sole schedule for all release work, + including the release-gate issue. Labels and Project fields do not schedule. +10. The release gate is the final open milestone issue and must close before + the signed local tag is created. + +Here `vX.Y.Z` means exact tag-form SemVer. Every prerelease uses its full +milestone title, such as `v0.3.0-alpha.2`. A valid release tag looks like one of these: @@ -73,6 +80,10 @@ published. If repo-resident evidence is missing at tag time, treat that as a process defect, file follow-up work, and fix the release preparation process before the next tag. +Historical release packets remain the evidence that was reviewed for their +tags. Do not rewrite them to conform retroactively to a newer scheduling model +or to insert facts that only became available after publication. + ## GitHub Actions Release Shape The release workflow is tag-triggered: @@ -94,8 +105,7 @@ The `release-gauntlet` job must verify: - every publishable crate has the minimum package file set - root `README.md` exists - root `CHANGELOG.md` contains release notes for the exact version -- no open GitHub Issue is associated with the exact tag or version by issue - title/body text, milestone, or label +- the exact plain `vX.Y.Z` milestone exists and has no open issue - Rust check, test, clippy, docs, release-check, package sanity, and audit pass The `publish-crates` job must depend on `release-gauntlet` and must repeat the @@ -139,6 +149,7 @@ Before mutating anything, determine and report: - latest reachable semver tag matching `v*` - current branch - exact sync state versus `origin/main` +- exact target `vX.Y.Z` milestone and its release-gate issue `ABORT` if any item cannot be determined confidently. @@ -156,6 +167,10 @@ policy has been provided. 7. `ABORT` if local `main` is ahead of or behind `origin/main`. 8. Verify signed-tag readiness for human-created release tags. 9. `ABORT` if signing is unavailable or misconfigured. +10. Query the open-milestones API and verify an exact `vX.Y.Z` title match. + `ABORT` if the milestone is missing, closed, or the query fails. Do not + treat an empty `gh issue list --milestone` result as existence proof; + GitHub CLI also returns that result for a missing milestone. ### Phase 2: Versioning And Lock-Step Sync @@ -202,17 +217,20 @@ Wesley crates when paired with an exact matching `version`. The Rust gauntlet for Wesley is: ```bash -cargo xtask release-prep-guard --version X.Y.Z cargo xtask preflight cargo xtask release-check cargo audit cargo xtask package-crates --version X.Y.Z ``` -The release workflow also checks open GitHub issues for the exact tag and -version using issue title/body text, milestone association, and label -association. Third-party comments and automatic cross-reference chatter are not -release-lane ownership. Any matching open issue blocks publication. +Do not run `release-prep-guard` in this pre-merge gauntlet. Phase 5 runs it +after the release-prep PR lands and the release gate closes. + +The release workflow checks only the exact plain `vX.Y.Z` milestone. Every open +issue in that milestone blocks publication, including the release gate. A +missing milestone or failed GitHub query is a hard failure. Version labels, +Project fields, grouping labels, issue titles, and issue bodies are not release +scheduling authorities. For a multi-crate release where later crates depend on earlier Wesley crates, the full registry-backed `cargo publish --dry-run` for dependent crates cannot @@ -253,10 +271,20 @@ git commit -m "chore(release): vX.Y.Z-alpha.1" 7. Verify `HEAD` equals `origin/main`. 8. Verify the release commit is already reachable from origin/main before creating the tag. +9. Verify every target-milestone issue except the release gate is closed, moved + to another exact version milestone, or explicitly cut. +10. Run a fresh `cargo xtask preflight` from synced `main` while the gate + remains open, and `ABORT` on failure. +11. Complete human sign-off and close the release-gate issue. +12. Run `cargo xtask release-prep-guard --version X.Y.Z` and `ABORT` unless the + exact milestone exists and has zero open issues. +13. Fetch `origin` again and `ABORT` unless `HEAD` still equals the validated + release commit and refreshed `origin/main`. ### Phase 6: Tag, Delivery, Release, And Monitoring -1. Create exactly one signed tag on the synced `main` commit: +1. Confirm the release-gate issue is closed, then create exactly one signed tag + on the synced `main` commit: ```bash git tag -s vX.Y.Z -m "release: vX.Y.Z" @@ -271,15 +299,23 @@ git tag -s vX.Y.Z-alpha.1 -m "release: vX.Y.Z-alpha.1" 2. Verify the tag points at the synced `main` commit. 3. Verify the tag signature. 4. `ABORT` if verification fails. -5. Run `cargo xtask release-guard --tag vX.Y.Z`. +5. Run `cargo xtask release-guard --tag vX.Y.Z` and do not push unless it + passes. If anything fails after gate closure, follow the runbook to resolve + remote tag state. Only a proven-absent tag permits local deletion and gate + reopening; present or indeterminate remote state permits neither. Never + delete or recreate a remote tag. 6. Push the exact release tag only. 7. Let GitHub Actions run the tag-triggered release workflow. -8. Create or verify the GitHub Release from the versioned changelog notes. +8. Verify that the tag workflow creates its draft GitHub Release and finalizes + that same release from the versioned changelog notes; do not create or + finalize a competing release manually. 9. Monitor every workflow triggered by the release tag. 10. Do not infer success from queued or in-progress jobs. 11. Verify crates.io directly for every published crate. 12. Do not merge manual release-evidence backfills to `main` for the release that just published. +13. Preserve post-publication evidence in the workflow run, finalized GitHub + Release, registry records, and direct delivery witness. `ABORT LOUDLY` if any of these fail: @@ -289,9 +325,26 @@ git tag -s vX.Y.Z-alpha.1 -m "release: vX.Y.Z-alpha.1" - GitHub Release creation - registry visibility +## Publication Failure Is Patch-Forward + +The public tag and every published crate version are immutable. + +- Rerun the same tag only when its source and package inputs are correct and the + failure is transient delivery infrastructure, credentials, permissions, + registry visibility, or GitHub Release/API state. +- If source, metadata, package contents, or checked-in workflow logic must + change, create a new patch-version milestone, fix `main`, and cut a new tag. +- If publication partially succeeded, never reuse an already-published crate + version for different bytes. A same-tag rerun may publish only missing crates + when the tagged source is already correct and the workflow verifies existing + versions before continuing. +- Keep the failed run and the successful rerun or patch-forward outcome in the + workflow, GitHub Release, and delivery witness. Do not rewrite the historical + release packet or move the tag. + ## Official Commands -Preparation and validation: +Release-branch preparation and validation: ```bash cargo xtask docs-check @@ -299,10 +352,23 @@ cargo check --workspace --all-targets cargo test --workspace --all-features cargo clippy --workspace --all-targets -- -D warnings cargo xtask release-check -cargo xtask release-prep-guard --version X.Y.Z cargo xtask package-crates --version X.Y.Z ``` +After the release-prep PR lands, repeat preflight from synced `main` while the +release gate remains open: + +```bash +cargo xtask preflight +``` + +After preflight passes, complete human sign-off, close the gate, and clear the +exact milestone: + +```bash +cargo xtask release-prep-guard --version X.Y.Z +``` + After creating the signed tag, verify the tag-specific guard: ```bash diff --git a/docs/JEDIT_CAPABILITY_EVIDENCE.md b/docs/JEDIT_CAPABILITY_EVIDENCE.md index 1b4067ff..7fe29a54 100644 --- a/docs/JEDIT_CAPABILITY_EVIDENCE.md +++ b/docs/JEDIT_CAPABILITY_EVIDENCE.md @@ -5,8 +5,8 @@ This note records Wesley-side evidence for the jedit-shaped capability path. It is not a progress tracker. -Live adoption work belongs in GitHub Issues, goalpost milestones, sibling repo -issues, and the [Wesley Roadmap Project](https://github.com/users/flyingrobots/projects/18). +Live adoption work belongs in GitHub Issues, plain version milestones, sibling +repo issues, and the [Wesley Roadmap Project](https://github.com/users/flyingrobots/projects/18). ## Scope diff --git a/docs/METHOD.md b/docs/METHOD.md index 5b91fc42..4f3a1ac2 100644 --- a/docs/METHOD.md +++ b/docs/METHOD.md @@ -29,34 +29,38 @@ The Wesley work doctrine: GitHub Issues, a loop, and honest bookkeeping. | **`docs/ARCHITECTURE.md`** | Authoritative system map and pipeline. | | **`docs/design/`** | Active design packets and cycle-bound doctrine. | | **GitHub Issues** | Live work slices and raw intake. | -| **GitHub Milestones** | Goalposts and versioned release gates. | -| **GitHub Projects** | Roadmap board views over issues and milestones. | +| **GitHub Milestones** | Sole scheduling authority for named releases. | +| **GitHub Projects** | Roadmap and release-outcome views over scheduled issues. | | **`AGENTS.md`** | Context recovery protocol for AI and humans. | | **`docs/METHOD.md`** | Repo work doctrine (this document). | ## Work Hierarchy -| Concept | Canonical Surface | Rule | -| :----------------------------- | :-------------------------------------------------------------------------- | :--------------------------------------------------------- | -| **Goalpost** | GitHub Milestone named `Goalpost: ...` | Groups implementation slices. | -| **Slice** | GitHub Issue | Assigned to exactly one goalpost milestone. | -| **Release** | GitHub Milestone named `Release: vX.Y.Z` | Holds release-gate issues, not every implementation slice. | -| **Release Gate** | GitHub Issue in a `Release: vX.Y.Z` milestone | Links to the goalposts and release-lane issues selected. | -| **Roadmap Board** | [Wesley Roadmap Project](https://github.com/users/flyingrobots/projects/18) | Board/view layer over live GitHub Issues. | -| **Triage/Release/Legend/Type** | GitHub labels | Intake, release scheduling, and classification metadata. | - -GitHub allows only one milestone per issue. Keep implementation issues in their -goalpost milestones. Put release checklist/gate issues in release milestones and -link the relevant goalposts from the gate issue body. +| Concept | Canonical Surface | Rule | +| :--------------------- | :-------------------------------------------------------------------------- | :--------------------------------------------------------------------------- | +| **Unscheduled Intake** | GitHub Issue | Exactly one `triage:*` label and no milestone. | +| **Scheduled Slice** | GitHub Issue | Exactly one plain `vX.Y.Z` milestone; no triage or version scheduling label. | +| **Release** | GitHub Milestone named exactly `vX.Y.Z` | Contains every implementation, docs, prep, and gate issue for the release. | +| **Release Gate** | GitHub Issue in the same version milestone | Final pre-tag issue; close it last. | +| **Release Outcome** | Release packet, tracking issue, or Project view | Narrative grouping only; never a scheduling authority. | +| **Roadmap Board** | [Wesley Roadmap Project](https://github.com/users/flyingrobots/projects/18) | View layer over live GitHub Issues. | +| **Classification** | GitHub labels | Intake, legend, type, ownership, and optional status metadata. | + +A plain version milestone is the sole schedule for a named release. Labels +classify work but never schedule it into a release. All work committed to a +release shares its version milestone; before tagging, move or close every other +open issue and close the release gate last. ## GitHub Issue Triage Use [Issue Triage](./topics/contributing/triage.md) for the label contract. Short version: -- `triage:*` labels are unscheduled intake. -- `vX.Y.Z` labels are scheduled work for named future releases. -- Do not use generic `lane:*` labels for active work. +- `triage:*` labels classify unscheduled intake; those issues have no + milestone. +- Plain `vX.Y.Z` milestones schedule named-release work; those issues have no + `triage:*` or concrete-version scheduling label. +- Do not use generic `lane:*` or concrete `vX.Y.Z` labels for scheduling. Legend labels preserve Wesley's work taxonomy: `legend:SOURCE`, `legend:TRANSMUTE`, `legend:RUNTIME`, `legend:EVIDENCE`, `legend:SPEC`, @@ -66,8 +70,9 @@ Legend labels preserve Wesley's work taxonomy: `legend:SOURCE`, Active work should carry `work-in-progress`. Follow-up work belongs in GitHub Issues, not in chat, TODO prose, or local-only backlog files. -Every open issue should carry exactly one scheduling-state label: either one -`triage:*` label or one `vX.Y.Z` label. +Every open issue must be in exactly one scheduling state: unscheduled with one +`triage:*` label and no milestone, or scheduled in one plain version milestone +with no triage or concrete-version scheduling label. ## The Cycle Loop @@ -83,9 +88,10 @@ stateDiagram-v2 Ship --> [*] ``` -1. **Pull**: Select a GitHub Issue, assign the right goalpost milestone, add it - to the Wesley Roadmap Project if missing, add `work-in-progress`, and link it - from any design doc frontmatter or body. +1. **Pull**: Select a GitHub Issue scheduled in exactly one plain version + milestone, confirm it has no triage or concrete-version scheduling label, + add it to the Wesley Roadmap Project if missing, add `work-in-progress`, + and link it from any design doc frontmatter or body. 2. **Branch**: Create a branch from the issue title slug. 3. **Red**: Write failing tests based on the design's playback questions. 4. **Green**: Implement the solution until tests pass. diff --git a/docs/README.md b/docs/README.md index b8751d35..75d29d5c 100644 --- a/docs/README.md +++ b/docs/README.md @@ -35,7 +35,7 @@ which signpost is supposed to answer which question. | [METHOD Release](./method/release.md) | How releases are shaped, verified, and documented. | | [Documentation Standard](./governance/DOCUMENTATION_STANDARD.md) | How Wesley docs stay useful without becoming a shadow backlog. | | [Wesley Roadmap Project](https://github.com/users/flyingrobots/projects/18) | Live GitHub Project for roadmap board views. | -| [GitHub Milestones](https://github.com/flyingrobots/wesley/milestones) | Live goalpost and release milestones. | +| [GitHub Milestones](https://github.com/flyingrobots/wesley/milestones) | Sole live schedule for named releases through plain `vX.Y.Z` milestones. | ## Current Center Of Gravity diff --git a/docs/architecture/hosts.md b/docs/architecture/hosts.md index e040421a..b87c954f 100644 --- a/docs/architecture/hosts.md +++ b/docs/architecture/hosts.md @@ -40,4 +40,5 @@ packages are not part of the Rust-native product spine. Host maturity and release scheduling are not tracked by README progress tables or filesystem milestone docs. Use GitHub Issues, Milestones, Projects, and -version labels for live planning state. +classification labels for live planning state. Plain `vX.Y.Z` milestones are +the release-scheduling authority. diff --git a/docs/design/0019-weslaw-semantic-law-ir/weslaw-semantic-law-ir.md b/docs/design/0019-weslaw-semantic-law-ir/weslaw-semantic-law-ir.md index 1958a289..2fa7ff1f 100644 --- a/docs/design/0019-weslaw-semantic-law-ir/weslaw-semantic-law-ir.md +++ b/docs/design/0019-weslaw-semantic-law-ir/weslaw-semantic-law-ir.md @@ -1264,7 +1264,7 @@ layer because it creates false confidence. ## Implementation Evidence The v1 runway is closed. This packet no longer carries a live slice ledger; live -work belongs in GitHub Issues and goalpost milestones. +work belongs in GitHub Issues and plain version milestones. Durable evidence from the closed runway covers: diff --git a/docs/design/0020-holmes-weslaw-assurance-prd-test-plan/holmes-weslaw-assurance-prd-test-plan.md b/docs/design/0020-holmes-weslaw-assurance-prd-test-plan/holmes-weslaw-assurance-prd-test-plan.md index 21003394..397b06e4 100644 --- a/docs/design/0020-holmes-weslaw-assurance-prd-test-plan/holmes-weslaw-assurance-prd-test-plan.md +++ b/docs/design/0020-holmes-weslaw-assurance-prd-test-plan/holmes-weslaw-assurance-prd-test-plan.md @@ -12,7 +12,7 @@ This packet is closed planning evidence. It is not a live implementation tracker. Live Holmes and `weslaw` implementation work belongs in GitHub Issues, -goalpost milestones, release-gate issues, and the +plain version milestones, release-gate issues, and the [Wesley Roadmap Project](https://github.com/users/flyingrobots/projects/18). ## Question diff --git a/docs/design/README.md b/docs/design/README.md index 2e990525..6dc1f1a5 100644 --- a/docs/design/README.md +++ b/docs/design/README.md @@ -3,8 +3,9 @@ Design packets live here as specifications, boundary notes, and durable evidence. They are not live progress trackers. -Work slices live in GitHub Issues. Goalposts live in GitHub Milestones. The -roadmap board lives in the +Work slices live in GitHub Issues. Scheduled work uses plain `vX.Y.Z` +milestones; unscheduled intake uses `triage:*` labels. Release outcomes are +narrative groups, not additional milestones. The roadmap board lives in the [Wesley Roadmap Project](https://github.com/users/flyingrobots/projects/18). Use [`TEMPLATE.md`](./TEMPLATE.md) as the starting point for every new design @@ -67,8 +68,8 @@ Current packets: substrate notes - [`0020`](./0020-holmes-weslaw-assurance-prd-test-plan/holmes-weslaw-assurance-prd-test-plan.md): completed Holmes `weslaw` assurance PRD and test-plan campaign. Rust Holmes - implementation work is tracked by GitHub Issues and goalpost milestones, not - packet-local status docs + implementation work is tracked by GitHub Issues and plain version milestones, + not packet-local status docs - [`0021`](./0021-continuum-yolo-runtime-neutral-edict-sha-lock-assurance/continuum-yolo-runtime-neutral-edict-sha-lock-assurance.md): extracted Continuum lawful-autonomous lane and runtime-neutral Edict packet; the canonical specs now live in diff --git a/docs/design/TEMPLATE.md b/docs/design/TEMPLATE.md index e29f9dc2..7cb427b7 100644 --- a/docs/design/TEMPLATE.md +++ b/docs/design/TEMPLATE.md @@ -32,15 +32,20 @@ updated: 'YYYY-MM-DD' ## GitHub Work -Name the issue, goalpost milestone, and project item this packet supports. +Name the issue, canonical scheduling state, narrative release outcome, and +project item this packet supports. Example: - Issue: `https://github.com/flyingrobots/wesley/issues/{number}` -- Goalpost milestone: `Goalpost: Make It Truthful` +- Scheduling milestone: `v0.4.0` +- Release outcome: `Make It Truthful` (narrative group, not a milestone) - Project: `https://github.com/users/flyingrobots/projects/18` -Do not use this section as a progress tracker. +Scheduled work must have one plain `vX.Y.Z` milestone and no `triage:*` or +version label. If the packet documents unscheduled intake, name its one +`triage:*` label and confirm that it has no milestone. Do not use this section +as a progress tracker. ## Cycle Preparation @@ -50,7 +55,8 @@ section as a progress checklist. Include: - branch basis from synced `origin/main` -- linked GitHub issue, goalpost milestone, and Project item +- linked GitHub issue, scheduling milestone or triage state, narrative release + outcome, and Project item - `work-in-progress` label state, when applicable - branch and PR links, when available diff --git a/docs/governance/DOCUMENTATION_STANDARD.md b/docs/governance/DOCUMENTATION_STANDARD.md index f4f15e39..626a7b0f 100644 --- a/docs/governance/DOCUMENTATION_STANDARD.md +++ b/docs/governance/DOCUMENTATION_STANDARD.md @@ -13,24 +13,25 @@ state. Live work state belongs in GitHub: -| Work State | Canonical Surface | -| -------------- | --------------------------------------------------------------------------- | -| Raw intake | GitHub Issue with a `triage:*` label | -| Goalpost | GitHub Milestone named `Goalpost: ...` | -| Slice | GitHub Issue assigned to one goalpost milestone | -| Release lane | GitHub label named `vX.Y.Z` | -| Release target | GitHub Milestone named `Release: vX.Y.Z` | -| Release gate | GitHub Issue assigned to the release milestone | -| Roadmap board | [Wesley Roadmap Project](https://github.com/users/flyingrobots/projects/18) | -| Classification | GitHub labels for triage, release lane, legend, work type, package, status | +| Work State | Canonical Surface | +| ------------------ | ------------------------------------------------------------------------------------------------ | +| Unscheduled intake | GitHub Issue with exactly one `triage:*` label and no milestone | +| Scheduled slice | GitHub Issue in exactly one plain `vX.Y.Z` milestone, with no triage or version scheduling label | +| Release target | GitHub Milestone named exactly `vX.Y.Z` | +| Release gate | Final pre-tag GitHub Issue assigned to the same version milestone | +| Release outcome | Narrative grouping in a release packet, tracking issue, or Project view | +| Roadmap board | [Wesley Roadmap Project](https://github.com/users/flyingrobots/projects/18) | +| Classification | GitHub labels for triage, legend, work type, package, ownership, and optional status | Repository docs may link to GitHub state. They must not mirror live counts, unchecked work queues, velocity, burn-down, or "next issue" lists. -Because GitHub Issues can have only one milestone, release milestones must not -steal implementation issues away from their goalpost milestones. A release -milestone holds release-gate issues; those gate issues link to the goalposts -selected for that release. +A plain version milestone is the sole scheduling authority for a named release. +Labels classify work but never schedule it. Every implementation, +documentation, preparation, and gate issue committed to a release shares its +version milestone. Before tagging, move or close every other open issue and +close the release gate last. Release outcomes remain narrative groupings, not +parallel schedules. ## Page Jobs @@ -104,17 +105,22 @@ For each public capability, keep one obvious path to: - release or closeout evidence when the behavior shipped recently The matrix can be implicit in signposts. It must not become a backlog. Missing -coverage should be filed as GitHub Issues and assigned to a goalpost. +coverage should be filed as GitHub Issues. Keep it unscheduled with one +`triage:*` label and no milestone, or schedule it in exactly one plain version +milestone with no triage or concrete-version scheduling label. ## Hard Rules - Do not add new Markdown backlog cards. - Do not add progress bars, score tables, live slice ledgers, or unchecked roadmap checklists to repo docs. -- Do not let `triage:*` remain on an issue after it has been scheduled into a - named release lane. -- Do not move implementation issues into release milestones. Link goalposts - from release-gate issues instead. +- Do not let `triage:*` remain on an issue after it has been assigned to a + plain version milestone. +- Do not create or use concrete-version labels, `Goalpost: ...` milestones, or + `Release: ...` milestones for scheduling. +- Do not use labels as a parallel release schedule. +- Keep every issue committed to a release, including its final pre-tag gate, in + that release's one plain version milestone. - Do not cite chat as proof. Use code, tests, commits, PRs, releases, workflow runs, GitHub Issues, or durable evidence files. - Do not create a new signpost unless it has a distinct reader job. diff --git a/docs/governance/RELEASE_CHECKLIST.md b/docs/governance/RELEASE_CHECKLIST.md index 4b551d91..0678bf5f 100644 --- a/docs/governance/RELEASE_CHECKLIST.md +++ b/docs/governance/RELEASE_CHECKLIST.md @@ -7,11 +7,20 @@ into the release PR body and complete it before creating the release tag. Automated checks are not listed here — those run inside `cargo xtask release-guard --tag vX.Y.Z` and block CI automatically. Before -creating a tag, run `cargo xtask release-check` locally; it runs the same +the release-prep PR, run `cargo xtask release-check` locally; it runs the same strict preflight gate used by `release-guard`, then builds and packages the -native release artifacts without publishing anything. This checklist covers -checks 7, 10, 13, 18, 22, 23, and 24 from the enforcement matrix, which require -human judgment. +native release artifacts without publishing anything. After the PR lands and +while the gate remains open, repeat `cargo xtask preflight`; close the gate only +after it passes, then run the post-merge `release-prep-guard`, create the signed +tag locally, and require the tag-specific `release-guard` to pass before push. +A post-gate failure reopens the gate only after the target tag is proven absent +from the remote. This checklist covers checks 7, 10, 13, 18, 22, 23, and 24 from +the enforcement matrix, which require human judgment. + +The exact plain `vX.Y.Z` milestone is the sole release schedule. Every release +issue, including the gate, belongs to it. Complete this review after every other +milestone issue is closed or moved; then close the gate before creating the +signed local tag. See [`RELEASE_POLICY.md`](RELEASE_POLICY.md) for the full enforcement matrix and rationale. @@ -50,15 +59,27 @@ and rationale. - [ ] **Release thesis and scope are honest** I confirmed the release packet records the thesis, must-ship work, - may-slip work, explicitly-not-included work, selected goalposts, acceptance + may-slip work, explicitly-not-included work, selected release outcomes, acceptance evidence, and retrospective/evidence location. Any planned work that did not ship was moved, cut, or acknowledged before tagging. +- [ ] **The exact version milestone is the complete schedule** + I confirmed the plain `vX.Y.Z` milestone exists and contains every piece + of scheduled implementation work plus the release-gate issue. No + `vX.Y.Z` label, Project field, grouping label, title, or body text is being + treated as a second scheduling axis. + +- [ ] **The release gate is the final pre-tag issue** + I confirmed every other issue in the target milestone is closed, moved to + another exact version milestone, or explicitly cut. The gate will be + closed before the signed local tag is created, and the exact-milestone guard + must pass afterward. + - [ ] **No known issues being silently shipped** - I reviewed the open GitHub Issues for known defects or outstanding decisions - that affect this release's correctness or safety, whether or not they are - already marked as release issues. Anything knowingly deferred is acknowledged - in the CHANGELOG or a documented follow-on issue. + I reviewed the target milestone and unscheduled GitHub Issues for known + defects or outstanding decisions that affect this release's correctness + or safety. Anything knowingly deferred is acknowledged in the CHANGELOG + or a documented follow-on issue with a valid scheduling state. - [ ] **Tagged `main` is the release boundary** I confirmed every repo-resident release fact that must ship with this @@ -66,6 +87,11 @@ and rationale. depend on a manual post-publish merge to make README, changelog, release notes, runbooks, or verification docs accurate. +- [ ] **Post-publication evidence has an external home** + I confirmed the tag workflow, finalized GitHub Release, registry records, + and direct delivery witness will retain publication evidence without a + manual evidence-backfill commit. + ### Notes _Add any relevant context, known deferred issues, or reviewer observations._ diff --git a/docs/governance/RELEASE_POLICY.md b/docs/governance/RELEASE_POLICY.md index e669c491..76d7e3b0 100644 --- a/docs/governance/RELEASE_POLICY.md +++ b/docs/governance/RELEASE_POLICY.md @@ -27,54 +27,71 @@ lacks the human sign-off is not a valid release, and vice versa. native CLI and packages release artifacts without publishing anything. - **Human sign-off** is collected on the release PR using the template in [`RELEASE_CHECKLIST.md`](RELEASE_CHECKLIST.md). The checklist must be - completed by a human reviewer before the tag is created. + completed by a human reviewer before the release gate is closed and the tag + is created. + +All release work and the release-gate issue share one exact plain `vX.Y.Z` +milestone. Project fields, grouping labels, titles, and body text do not +schedule work. The gate is the final open issue and must close before the +signed local tag is created. + +Here `vX.Y.Z` means the exact tag-form SemVer. Every prerelease uses its full +milestone title, such as `v0.3.0-alpha.2`. + +The final local sequence is ordered: repeat preflight on synced `main` while +the gate is open; close the gate only after it passes; run the post-merge +tracker-clear guard; reverify the unchanged `main` commit; create the signed tag +locally; run the tag-specific guard; then push. After gate closure, resolve +remote tag state before recovery: only a proven-absent tag permits local-tag +deletion and gate reopening. Present or indeterminate remote state permits +neither mutation, and a remote tag is immutable. ## Enforcement Matrix -| # | Check | Automated | Human | -| --- | ---------------------------------------------------- | -------------- | -------- | -| 1 | Zero open current release-lane GitHub issues | `xtask` + `gh` | | -| 2 | Zero open exact-version tracker references | `xtask` + `gh` | | -| 3 | Strict preflight gate | `xtask` | | -| 4 | Zero open issues from prior-version lanes | `gh` | | -| 5 | Version lockstep across release version sources | parse | | -| 6 | `CHANGELOG.md` has a dated entry for this version | parse | | -| 7 | `CHANGELOG.md` reflects actual diff vs. prior tag | | reviewer | -| 8 | `README.md` version headline matches tag | grep | | -| 9 | `docs/TECHNICAL_TEARDOWN.md` references tag version | grep | | -| 10 | `docs/ARCHITECTURE.md` is current | | reviewer | -| 11 | Guide file paths resolve to existing repo paths | grep + stat | | -| 12 | Guide cited commit SHAs exist in git history | git cat-file | | -| 13 | Guide claims are accurate | | reviewer | -| 14 | `docs-truth` manifest passes | `xtask` | | -| 15 | `cargo audit` reports zero vulnerabilities | shell | | -| 16 | No WIP or `fixup!` commits in release range | git log | | -| 17 | Working tree is clean | git status | | -| 18 | Tag is the synced `main` release boundary | git branch | reviewer | -| 19 | CI is green on HEAD at tag time | `gh` API | | -| 20 | `BREAKING CHANGE` commits → major/minor version bump | git log | | -| 21 | `cargo doc --workspace` builds with zero warnings | cargo doc | | -| 22 | No known issues silently shipped | | reviewer | -| 23 | `docs/topics/` accuracy and coverage gate | | reviewer | -| 24 | Release thesis, scope, and retrospective path exist | | reviewer | +| # | Check | Automated | Human | +| --- | ----------------------------------------------------- | -------------- | -------- | +| 1 | Exact plain `vX.Y.Z` milestone exists | `xtask` + `gh` | | +| 2 | Zero open issues in the target version milestone | `xtask` + `gh` | | +| 3 | Strict preflight gate | `xtask` | | +| 4 | No label, Project, title, or body scheduling fallback | unit tests | | +| 5 | Version lockstep across release version sources | parse | | +| 6 | `CHANGELOG.md` has a dated entry for this version | parse | | +| 7 | `CHANGELOG.md` reflects actual diff vs. prior tag | | reviewer | +| 8 | `README.md` version headline matches tag | grep | | +| 9 | `docs/TECHNICAL_TEARDOWN.md` references tag version | grep | | +| 10 | `docs/ARCHITECTURE.md` is current | | reviewer | +| 11 | Guide file paths resolve to existing repo paths | grep + stat | | +| 12 | Guide cited commit SHAs exist in git history | git cat-file | | +| 13 | Guide claims are accurate | | reviewer | +| 14 | `docs-truth` manifest passes | `xtask` | | +| 15 | `cargo audit` reports zero vulnerabilities | shell | | +| 16 | No WIP or `fixup!` commits in release range | git log | | +| 17 | Working tree is clean | git status | | +| 18 | Tag is the synced `main` release boundary | git branch | reviewer | +| 19 | CI is green on HEAD at tag time | `gh` API | | +| 20 | `BREAKING CHANGE` commits → major/minor version bump | git log | | +| 21 | `cargo doc --workspace` builds with zero warnings | cargo doc | | +| 22 | No known issues silently shipped | | reviewer | +| 23 | `docs/topics/` accuracy and coverage gate | | reviewer | +| 24 | Release thesis, scope, and retrospective path exist | | reviewer | ## Automated Checks — Details ### Check 1–4: Issue Tracker -`cargo xtask release-guard` calls the GitHub CLI to list open issues: +`cargo xtask release-guard` calls the GitHub CLI with the exact milestone name: -- **Check 1** — Current release lane: open issues labeled with the concrete - release label `vX.Y.Z` block that release. Those issues are scheduled - work for the release being cut, so they must be closed, moved to a later - release lane, split, or explicitly removed from the release before tagging. -- **Check 2** — Exact-version tracker references: open issues labeled or - milestoned with the release tag or version (e.g. `v0.1.0`) or matching the - exact tag/version token in issue title or body. Comments and automatic - cross-reference chatter are not release-lane ownership. -- **Check 4** — Prior-version issues: open issues from older version lanes - (older `v*` labels, SemVer milestones, or exact SemVer labels) that were - never closed. +- **Check 1** — Milestone existence: the open-milestones API must contain an + exact title match for `vX.Y.Z`. This is a separate check because + `gh issue list --milestone ` exits successfully with an empty list. + A missing or closed target milestone is a hard failure. +- **Check 2** — Target milestone clear: every issue in `vX.Y.Z`, including the + release gate, must be closed or moved before tagging. The guard enumerates + those issues with `gh issue list --milestone vX.Y.Z`. Labels, Project fields, + issue titles, and issue bodies are not additional scheduling selectors. +- **Check 4** — No scheduling fallback: target-scope queries use only the exact + version milestone. Version labels, Project fields, issue titles, and issue + bodies neither add blockers nor excuse an open milestone issue. ### Check 3: Strict Preflight Gate @@ -221,8 +238,8 @@ A human reviewer must confirm that no open GitHub Issues represent known defects or outstanding decisions that affect this release's correctness or safety, and are being knowingly shipped without acknowledgment in the CHANGELOG or a documented follow-on issue. Automated issue-tracker checks surface issues by -version label and milestone; they cannot detect an issue that was never labeled -but is nonetheless blocking. +the exact version milestone; they cannot detect an unscheduled issue that is +nonetheless blocking. ### Check 23: `docs/topics/` Accuracy and Coverage Gate @@ -243,7 +260,7 @@ post-release merge to make the released commit truthful. ### Check 24: Release Thesis, Scope, And Retrospective Path A human reviewer must confirm planned releases have a current release thesis, -must-ship/may-slip/not-included scope, two to five goalposts with acceptance +must-ship/may-slip/not-included scope, two to five release outcomes with acceptance evidence, and an explicit retrospective/evidence location under `docs/method/releases/vX.Y.Z/`. Patch and emergency releases may use a shorter thesis, but they still need a recorded reason, validation evidence, @@ -254,10 +271,12 @@ post-publication verification, and fallout issue path. If a release is discovered to have shipped in violation of this policy: 1. File a `triage:bad-code` GitHub Issue immediately documenting the violation, - or schedule the corrective fix into a concrete patch release lane if the + or schedule the corrective fix into a concrete patch release milestone if the release target is already known. -2. Do not attempt to retroactively fix the published crate — crates.io publishes - are permanent. +2. Do not move or overwrite the published tag and do not attempt to replace the + published crate — both are immutable release facts. 3. If the violation involves a security defect, follow `SECURITY.md`. 4. Issue a corrective patch release at the earliest opportunity. -5. Post-mortem the gate failure and update xtask checks to prevent recurrence. +5. Keep the failed publication evidence in the workflow run, GitHub Release, + and delivery witness; patch forward instead of rewriting release history. +6. Post-mortem the gate failure and update xtask checks to prevent recurrence. diff --git a/docs/governance/labels.md b/docs/governance/labels.md index 181532a0..11f93e47 100644 --- a/docs/governance/labels.md +++ b/docs/governance/labels.md @@ -23,7 +23,6 @@ GitHub and can be reviewed with `gh label list`. | `status: non-blocking` | Nice-to-have items that are not release blockers. | | `pkg:*` | Ownership hints for the affected package(s). | | `triage:*` | Unscheduled intake classification. | -| `v*` | Named future release scheduling. | | `legend:*` | Wesley legend classification. | | `work:*` | Product, integrity, or enabler work shape. | @@ -37,12 +36,20 @@ flow. | `triage:requests` | Raw requests and incoming asks. | | `triage:bad-code` | Debt intake awaiting scheduling, split, move, or closure. | | `triage:cool-ideas` | Idea intake awaiting scheduling, split, move, or closure. | -| `vX.Y.Z` | Work scheduled for a named future release. | -Every open issue should carry exactly one scheduling-state label: either one -`triage:*` label or one `vX.Y.Z` label. Do not use generic labels such as -`lane:asap`, `lane:inbox`, `lane:bad-code`, `lane:cool-ideas`, -`lane:release`, or `lane:planned` for active work. +Every open issue must be in exactly one scheduling state: + +- unscheduled with exactly one `triage:*` label and no milestone; or +- scheduled in exactly one plain `vX.Y.Z` milestone with no `triage:*` or + concrete-version scheduling label. + +Milestones are the sole named-release schedule. Labels classify only. Concrete +`vX.Y.Z` scheduling labels and generic labels such as `lane:asap`, +`lane:inbox`, `lane:bad-code`, `lane:cool-ideas`, `lane:release`, and +`lane:planned` are retired and must not be used for active work. Every issue +committed to a release shares its plain version milestone; the release gate is +the final pre-tag issue in that milestone. Release outcomes are narrative +groupings, not alternate schedules. ## Legends @@ -62,14 +69,18 @@ Legend labels preserve Wesley's work taxonomy: ## Label conventions - Every newly opened issue should get **one work-type label** (bug/feature/chore/docs/tests). -- Every implementation issue should have a goalpost milestone. -- Release-gate issues should have a `Release: ...` milestone. +- Keep new unscheduled intake in exactly one `triage:*` classification and no + milestone. +- Schedule work by assigning exactly one plain `vX.Y.Z` milestone and removing + every triage or concrete-version scheduling label. +- Keep the release-gate issue in that same milestone and close it last before + tagging. - Use **module labels** (`pkg:*`) when the work sits in a single package; skip them for cross-cutting features. - Add `good first issue` only if the description already includes clear steps and the acceptance criteria can be completed without repo-wide context. - `status: non-blocking` is a reminder that an issue can be deferred without - risking the next milestone. + risking the target release. ## Adding new labels diff --git a/docs/method/guide.md b/docs/method/guide.md index 6ee97071..0614a5ad 100644 --- a/docs/method/guide.md +++ b/docs/method/guide.md @@ -7,9 +7,10 @@ It is lighter than doctrine in `README.md` or `docs/method/process.md`. If a work-worthy idea surfaces during the work, capture it now. -Open a GitHub Issue with the right `triage:*` intake label, release lane if the -target release is already known, and legend labels instead of leaving the idea -only in chat, in a dirty worktree, or buried inside a retro. +Open a GitHub Issue in one canonical scheduling state: unscheduled with exactly +one `triage:*` label and no milestone, or scheduled with one plain `vX.Y.Z` +milestone and no `triage:*` or version label. Add classification labels instead +of leaving the idea only in chat, in a dirty worktree, or buried inside a retro. ## Retro versus Issues diff --git a/docs/method/process.md b/docs/method/process.md index 640b3b51..0b46325c 100644 --- a/docs/method/process.md +++ b/docs/method/process.md @@ -8,14 +8,15 @@ direction, and evidence. ## Rules -- The queue lives in GitHub Issues. Unscheduled intake uses `triage:*` labels; - scheduled work uses concrete release labels such as `v0.2.0`. +- The queue lives in GitHub Issues. Unscheduled intake has exactly one + `triage:*` label and no milestone. Scheduled work has exactly one plain + `vX.Y.Z` milestone and no `triage:*` or version label. - The full triage flow lives in [`docs/topics/contributing/triage.md`](../topics/contributing/triage.md). -- Goalposts live in GitHub Milestones named `Goalpost: ...`. -- Versioned releases live in GitHub Milestones named `Release: ...`; their - release-gate issues link to goalpost milestones because GitHub allows one - milestone per issue. +- Plain version milestones are the sole scheduling authority. Other labels + classify work; version labels are retired. +- Release outcomes are narrative groups inside a release's planning and gate + issue, not additional `Goalpost: ...` or `Release: ...` milestones. - The roadmap board is the [Wesley Roadmap Project](https://github.com/users/flyingrobots/projects/18). - Pulling work into `docs/design//` is commitment. @@ -46,19 +47,18 @@ direction, and evidence. ## Default Loop 1. Pull a GitHub Issue into `docs/design//` when a design packet is - needed, assign its goalpost milestone, and add it to the Wesley Roadmap - Project if missing. - If the issue still has a `triage:*` label, schedule, split, move, or close it - before implementation. + needed and add it to the Wesley Roadmap Project if missing. Before + implementation, assign exactly one plain `vX.Y.Z` milestone and remove all + `triage:*` and version labels, or split, move, or close the issue. 2. Write the design with both human and agent sponsors named. 3. Write failing tests from the playback questions. 4. Make the tests pass. 5. Produce a reproducible playback witness. 6. Close the cycle packet with a retro in `docs/method/retro//` and a `witness/` directory that records playback and verification evidence. -7. Reconcile GitHub Issue labels, milestone, and Project state; close, move, or - label genuinely rejected or retired work in GitHub instead of letting stale - repo evidence drift silently. +7. Reconcile the GitHub Issue's scheduling milestone, classification labels, + and Project state; close, move, or label genuinely rejected or retired work + in GitHub instead of letting stale repo evidence drift silently. 8. After merge, update `docs/BEARING.md`, `CHANGELOG.md`, and release notes when the merged state changes them. diff --git a/docs/method/release-runbook.md b/docs/method/release-runbook.md index 7ae33e5b..8d8ee31d 100644 --- a/docs/method/release-runbook.md +++ b/docs/method/release-runbook.md @@ -19,6 +19,10 @@ names, and verification commands. output on failure. - Ensure the working tree is clean; abort if dirty. - Confirm `main` is exactly synced with `origin/main`; abort if not. +- Confirm the exact plain `vX.Y.Z` milestone exists; abort if the GitHub query + cannot resolve it. +- Confirm every scheduled issue, including the release gate, uses that milestone + and no version label or narrative milestone as a second schedule. - Verify required tools, credentials, signing configuration, CI visibility, and registry visibility are available; abort if missing. - Ensure every required validation and publish verification step succeeds; @@ -36,6 +40,8 @@ Before changing anything, determine and record: - latest reachable semver tag matching `v*` - current branch - exact sync state versus `origin/main` +- exact target `vX.Y.Z` milestone and its open issue set +- release-gate issue in that milestone If any discovery item cannot be determined confidently, abort. @@ -48,6 +54,9 @@ Run these in order: 3. Fetch `origin/main` and tags. 4. Verify `HEAD` exactly matches `origin/main`. 5. Verify tag-signing requirements if the repository requires signed tags. +6. Query the open-milestones API and verify an exact `vX.Y.Z` title match. Do + not use an empty `gh issue list --milestone` result as existence proof; + GitHub CLI also returns an empty successful result for a missing milestone. Do not continue past the first failed guard. @@ -73,7 +82,6 @@ Run validation strictly in order, using repo-native commands where available: - audit every tracked file under `docs/topics/` for release-relevant accuracy and coverage - release pre-flight script, if the repo already has one -- `cargo xtask release-prep-guard --version X.Y.Z`, before the tag exists - `cargo xtask preflight` - `cargo xtask release-check` - `cargo xtask package-crates --version X.Y.Z`, before the tag exists @@ -108,56 +116,105 @@ reach the 90% accuracy and 90% coverage floors before tagging. Abort on the first hard failure. Do not claim success from queued or in-progress CI state. -## Phase 4: Commit, Tag, and Publish +The final `cargo xtask release-prep-guard --version X.Y.Z` is intentionally not +run while the release gate remains open. It is the post-merge, pre-tag tracker +guard in Phase 4. + +## Phase 4: Commit, Close Gate, Pre-Tag Checks, Tag, and Publish 1. Review the final diff. 2. Stage the release changes. 3. Create the release commit on a release branch. 4. Land the release commit through the protected `main` branch. -5. Sync local main to origin/main after the release commit has landed. -6. Create the release tag on the synced `main` commit. -7. Verify the tag points at the release commit and satisfies signing - requirements where applicable. -8. Run `cargo xtask release-guard --tag vX.Y.Z` after the tag exists locally. -9. Push the exact release tag only, for example: `git push origin vX.Y.Z`. -10. Create the GitHub Release or equivalent forge release using the versioned - release notes. -11. Monitor triggered workflows to completion. -12. Verify registries directly before claiming publication succeeded. -13. Record release evidence and retrospective before starting the next planned - release train. +5. Sync local `main` to `origin/main` after the release commit has landed and + record the exact validated `HEAD` commit. +6. Verify every issue except the gate has been closed, moved to another exact + version milestone, or explicitly cut. +7. Run a fresh `cargo xtask preflight` from synced `main`; if it fails, leave + the release gate open and land corrections through a pull request. +8. Complete the human checklist and close the release-gate issue. +9. Run `cargo xtask release-prep-guard --version X.Y.Z`; abort if the exact + `vX.Y.Z` milestone is missing or still has any open issue. +10. Fetch `origin` again. +11. Abort unless `HEAD` still equals both the recorded validated commit and the + refreshed `origin/main`. +12. Create the release tag locally on that unchanged, synced `main` commit. +13. Verify the tag points at the release commit and satisfies signing + requirements where applicable. +14. Run `cargo xtask release-guard --tag vX.Y.Z` after the tag exists locally; + abort before push on any failure. +15. Push the exact release tag only, for example: `git push origin vX.Y.Z`. +16. Monitor the tag-triggered workflow that creates its draft GitHub Release, + publishes artifacts, verifies delivery, and finalizes that same release. + Do not create or finalize a competing release manually. +17. Verify registries directly before claiming publication succeeded. +18. Preserve post-publication evidence in the workflow run, finalized GitHub + Release, registry records, and direct delivery witness. ## Idempotency And Failure Handling The public tag is immutable. Do not move it. +If any check or action fails after the gate closes but before the tag is pushed: + +1. Do not push again. Resolve the exact remote state with + `git ls-remote --exit-code --tags origin refs/tags/vX.Y.Z`. Exit zero means + the tag is already public; exit two with no matching ref means it is proven + absent. Any authentication, transport, or other result is indeterminate. +2. If the tag is present remotely, do not delete the local tag and do not reopen + the gate. Stop this recovery and use the immutable public-tag failure path + below. +3. If remote state is indeterminate, change neither the tag nor the gate. Stop + until remote state can be resolved conclusively. +4. Only when the tag is proven absent may local recovery continue. If a local + tag was created, delete only that unpublished tag with `git tag -d vX.Y.Z`; + if failure occurred before local tag creation, skip deletion. +5. Reopen the release gate, record the failed check, and assign any corrective + issue to the exact version milestone. +6. Land corrections through the normal pull-request path, then repeat the + post-merge pre-tag checks before recreating the signed local tag. + +An unpublished local tag is recoverable scratch state. A tag that exists on the +remote is a public release fact and must never be deleted, moved, or recreated. + If the tag exists but publication did not complete: 1. Keep the tag fixed. 2. Determine whether the failure is in release inputs or release machinery. -3. Same-tag reruns are for credentials, permissions, transient registry, or - GitHub Release/API problems. +3. Same-tag reruns are allowed only when the tagged source is correct and the + failure is credentials, permissions, transient registry state, or GitHub + Release/API delivery. 4. If the workflow source checked into the tag is wrong, do not rerun the immutable tag expecting a later workflow fix to apply. -5. For workflow-source failures, patch forward from `main`, or use an explicit - maintainer-approved manual recovery only when no public artifact escaped and - the recovery still verifies the same tagged source. -6. Record both the failure and the successful rerun or patch-forward release in - release evidence. +5. If any source, metadata, package, or workflow-input change is required, open + the next patch milestone and patch forward from `main` under a new version + and tag. +6. Use explicit maintainer-approved manual recovery only when no source change + is required, no contradictory public artifact escaped, and the recovery + still verifies the same tagged source. Manual recovery must never create, + edit, finalize, or recreate the GitHub Release; the tag workflow exclusively + owns that lifecycle. +7. Preserve both the failure and the successful rerun or patch-forward release + in workflow, GitHub Release, and delivery-witness evidence. If one crate publishes and another fails: 1. Keep the tag fixed. 2. Confirm the already-published crate versions match the tag source. -3. Fix the failing crate path. -4. Rerun the workflow so it verifies existing crates and publishes only missing +3. If the tagged inputs are correct and the failure was transient, rerun the + idempotent workflow so it verifies existing crates and publishes only missing artifacts where crates.io allows that path. +4. If a source or package fix is required, do not mutate the tag or reuse an + already-published version; cut a corrective patch release. If the GitHub Release fails: 1. Keep the tag fixed. -2. Recreate or update the GitHub Release for the same tag. -3. Verify the release notes match the tag source. +2. If the tagged inputs and checked-in workflow are correct, rerun the same tag + workflow so it resumes or recreates its own draft and finalizes that release. +3. If source or checked-in workflow changes are required, patch forward under a + new version and tag; do not mutate the failed release manually. +4. Verify the workflow-owned release notes match the tag source. If a published artifact is bad: @@ -168,35 +225,41 @@ If a published artifact is bad: 4. File fallout issues and record the patch-forward decision. Manual tagging is not an emergency bypass in Wesley. It is the normal mechanism -because the release profile declares `autotag: none`. It still must happen only -after final guards pass from clean, fetched, synced `main`. +because the release profile declares `autotag: none`. Create the local tag only +after post-merge pre-tag checks pass from clean, fetched, synced `main`, and push +it only after the tag-specific guard passes. -## Phase 5: Retrospective And Closeout +## Phase 5: Verification, Retrospective, And Closeout After the tag workflow publishes and public verification passes: -1. Update `docs/method/releases/vX.Y.Z/verification.md` with tag, commit, - workflow, GitHub Release, registry, smoke, and warning evidence. -2. Record plan-versus-actual scope: shipped, slipped, cut, expanded, and why. -3. Identify repeatable wins and concrete process improvements. -4. File fallout issues with a definition of done and exactly one scheduling - state label. -5. Close or move every scoped issue. -6. Close the release milestone after release-gate work is complete. +1. Verify the workflow, finalized GitHub Release, crates.io records, and direct + CLI smoke witness. These are the authoritative post-publication evidence. +2. Do not edit the tagged release packet merely to backfill facts that did not + exist before publication. Preserve historical release packets as they were + reviewed and tagged. +3. Record plan-versus-actual scope: shipped, slipped, cut, expanded, and why. +4. Identify repeatable wins and concrete process improvements. +5. File fallout issues with a definition of done and either one future plain + version milestone or one `triage:*` label with no milestone. +6. Close the now-empty release milestone. 7. Write or refresh the next release thesis before making the next planned train active. ## Evidence -Record the release witness in `docs/method/releases/vX.Y.Z/verification.md`. At -minimum include: +Before tagging, record the repo-resident validation witness in +`docs/method/releases/vX.Y.Z/verification.md`. At minimum include: - discovery facts - commands run - pass/fail results -- tag and commit SHAs -- GitHub Release URL -- registry URLs +- intended tag and candidate commit SHA +- intended GitHub Release and registry verification paths - `docs/topics/` accuracy and coverage scores, with links to any corrections - any non-blocking warnings -- retrospective summary and fallout issue links + +After publication, keep the actual workflow URL, finalized GitHub Release, +registry URLs, smoke output, warnings, and patch-forward decisions in the +workflow/GitHub Release/delivery witness. Do not require a post-publication +commit to make the tagged source truthful. diff --git a/docs/method/release.md b/docs/method/release.md index 559d4790..d162252d 100644 --- a/docs/method/release.md +++ b/docs/method/release.md @@ -18,15 +18,16 @@ automation and reviewers should enforce. Wesley uses the Continuum spine, but the generic template must be adapted in these important ways: -| Generic lifecycle point | Wesley adaptation | -| ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | -| Version bucket | Implementation issues stay in `Goalpost: ...` milestones. Concrete `vX.Y.Z` labels are the scheduling axis. | -| Release milestone | `Release: vX.Y.Z` milestones hold release-gate and closeout issues only. They are not queried as pre-tag blockers. | -| Autotag | `.continuum/release.yml` declares `autotag: none`. Maintainers create a signed tag manually after final guards pass from synced `main`. | -| Package/channel policy | crates.io is the public package registry. npm, JSR, and dist-tag policy do not apply to the current Wesley release surface. | -| Publication | `.github/workflows/release-crates.yml` runs from the tag and must verify tag, metadata, main reachability, package visibility, and GitHub Release. | -| Public release boundary | The tag must point at the exact reviewed `main` commit. Do not merge post-release fixes into `main` and pretend they are part of the same release. | -| Domain boundary | Release scope must not add downstream domain semantics to Wesley core. Extensions and sibling repos own meaning. | +| Generic lifecycle point | Wesley adaptation | +| ----------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | +| Version bucket | One exact plain `vX.Y.Z` milestone is the sole schedule for implementation work and its release gate. | +| Release gate | The gate shares the version milestone, is its final open issue, and closes before the signed local tag is created. | +| Grouping and priority | GitHub Project fields and classification labels may group or prioritize work, but they never schedule a release. | +| Autotag | `.continuum/release.yml` declares `autotag: none`. Maintainers tag locally after pre-tag checks, then run the tag-specific guard before push. | +| Package/channel policy | crates.io is the public package registry. npm, JSR, and dist-tag policy do not apply to the current Wesley release surface. | +| Publication | `.github/workflows/release-crates.yml` runs from the tag and must verify tag, metadata, main reachability, package visibility, and GitHub Release. | +| Public release boundary | The tag points at the exact reviewed `main` commit. A bad public release is corrected by a later patch; its tag and published artifacts never move. | +| Domain boundary | Release scope must not add downstream domain semantics to Wesley core. Extensions and sibling repos own meaning. | The root [release process](../../RELEASE.md) is a thin maintainer entrance. This page remains the doctrine. The command-by-command execution layer remains @@ -38,18 +39,20 @@ A valid Wesley release has all of the following: 1. **A reason**: planned releases have a release thesis before implementation scope becomes active. -2. **A bucket**: Wesley uses GitHub for live work state. Implementation issues - stay in `Goalpost: ...` milestones; release-gate issues stay in - `Release: vX.Y.Z` milestones; concrete `vX.Y.Z` labels are the version - scheduling axis because GitHub issues can carry only one milestone. Release - guards query labels and exact-version references for blockers, not the - release-gate milestone itself. +2. **One schedule**: Wesley uses one exact plain `vX.Y.Z` GitHub milestone for + every scheduled issue, including the release gate. An unscheduled issue has + exactly one `triage:*` label and no milestone. Project fields, grouping + labels, issue titles, and body text may explain work, but they do not + schedule it. Release guards query only the exact version milestone and fail + if it is missing. + Here `vX.Y.Z` means exact tag-form SemVer. Every prerelease uses its full + milestone title, such as `v0.3.0-alpha.2`. 3. **Honest scope**: must-ship, may-slip, and explicitly-not-included work is recorded before release prep. 4. **A reviewed source commit**: the release tag points at the exact `main` commit that passed release prep. -5. **An immutable public tag**: signed public tags are not moved. Bad releases - are fixed by patching forward. +5. **An immutable public tag**: the release gate closes before tagging. Signed + public tags are never moved, and bad releases are fixed by patching forward. 6. **Synchronized metadata**: every version source declared in the release profile agrees. 7. **Updated signposts**: changelog, README, guide, architecture, topics, @@ -61,8 +64,9 @@ A valid Wesley release has all of the following: evidence are produced from the tagged source. 10. **Post-publication verification**: a release is done when consumers can see and use the crates, not when the upload command returns. -11. **Evidence**: tag, commit, workflow, artifact, verification, and - retrospective evidence remain inspectable. +11. **Evidence**: the tagged commit carries all repo-resident truth available + before publication. The tag workflow, GitHub Release, registry, and direct + delivery witness retain post-publication evidence without a backfill merge. 12. **Learning**: planned releases end with a retrospective and fallout issues. ## Lifecycle @@ -83,40 +87,53 @@ planned ### planned -A release is planned when a `Release: vX.Y.Z` milestone exists, a release-gate -issue exists, the release thesis exists, must-ship/may-slip/not-included scope -is recorded, two to five goalposts are named, and acceptance evidence is clear. +A release is planned when the exact plain `vX.Y.Z` milestone exists, its +release-gate issue exists in that milestone, the release thesis exists, +must-ship/may-slip/not-included scope is recorded, two to five release outcomes +are named, and acceptance evidence is clear. ### active -A release is active when it is the current version train, at least one selected -goalpost issue is in progress, priority labels reflect the real queue, and -exactly one active slice or tracking issue is marked as active for the train. +A release is active when it is the current version train, at least one issue in +its version milestone is in progress, Project priority and workflow fields +reflect the real queue, and exactly one active slice or tracking issue is marked +as active for the train. ### release-prep A release enters prep when implementation scope is reconciled against the previous public tag, slipped work is moved or cut, version metadata is updated, release signposts are updated, local release-prep validation passes, and a -`release/vX.Y.Z` branch exists. +`release/vX.Y.Z` branch exists. The gate remains open while other milestone +work remains and becomes the final pre-tag issue. ### merged A release is merged when the release-prep PR has approval or explicit maintainer admin authorization, CI is green, release-prep validation has passed, and the release branch has landed on `main`. The merge commit is the candidate release -commit. +commit. At this point every other issue in the version milestone must already +be closed, moved to another exact version milestone, or cut. ### tagged -A release is tagged when final preflight passes from synced `main`, the expected -tag does not already exist, and an annotated signed tag is created at the exact -candidate release commit. +A release is tagged locally only after a fresh `cargo xtask preflight` passes +from synced `main` while the gate remains open, the gate then closes, the +post-merge `release-prep-guard` confirms the exact version milestone has zero +open issues, the validated commit still equals refreshed `origin/main`, the +expected tag does not already exist, and an annotated signed tag is created at +that exact candidate release commit. The tag-specific `release-guard` then must +pass against that local tag before it is pushed. ```bash git tag -s vX.Y.Z -m "release: vX.Y.Z" ``` +The public immutability boundary is the tag push. After a post-gate failure, +follow the runbook to resolve remote tag state. Reopen the gate and delete a +local tag only when that tag is proven absent from the remote; mutate neither +when it is present or indeterminate. + ### published A release is published when `.github/workflows/release-crates.yml` checks out @@ -144,7 +161,7 @@ exists; and the next active slice is selected. ## Release Types - **Planned release**: normal minor, major, and meaningful patch trains. Requires - thesis, scoped issues, goalposts, release-prep PR, full validation, + thesis, scoped issues, release outcomes, release-prep PR, full validation, publication evidence, and retrospective. - **Patch release**: compatible bug fixes, packaging fixes, docs corrections tied to current behavior, and narrow operator workflow improvements. Requires @@ -190,8 +207,10 @@ Required release artifacts: - why this exact version number is justified - whether migration guidance is required - `docs/method/releases/vX.Y.Z/verification.md` - Internal release witness. It records discovery, pre-flight validation, - tag/publish evidence, and direct verification of delivery. + Internal pre-tag release witness. It records discovery, pre-flight validation, + the intended tag/publish path, and the direct verification plan. Actual + post-publication facts remain in the workflow run, finalized GitHub Release, + registry records, and direct delivery witness rather than a backfill commit. - `docs/releases/vX.Y.Z.md` User-facing release notes and migration guide. - `CHANGELOG.md` @@ -213,12 +232,13 @@ Commit history, diff inspection, and validation can support or challenge that judgment during pre-flight, but they do not silently own the decision by themselves. -## Goalposts And Evidence +## Release Outcomes And Evidence -A planned release should have two to five goalposts. Each goalpost names an -outcome, issue set, and observable acceptance evidence. Good evidence includes -command output, test results, workflow runs, registry lookups, documentation -links, smoke tests, closed issues, and merged PRs. +A planned release should have two to five named release outcomes. Each outcome +names an issue set and observable acceptance evidence without creating a second +scheduling axis. Good evidence includes command output, test results, workflow +runs, registry lookups, documentation links, smoke tests, closed issues, and +merged PRs. ## Signposts @@ -260,36 +280,39 @@ workflows. 1. Shape the release in `docs/method/releases/vX.Y.Z/release.md`. 2. Confirm the release profile still matches the repo. -3. Accept the release thesis, scope, goalposts, and version justification. +3. Accept the release thesis, scope, release outcomes, and version justification. 4. Draft the user-facing release notes in `docs/releases/vX.Y.Z.md`. 5. Reconcile scope against the previous public tag. 6. Run the sequential pre-flight in `docs/method/release-runbook.md`. 7. Land the release-prep PR on `main`. -8. Create the signed tag on synced `main`. -9. Publish from the tag. -10. Verify delivery directly. -11. Record the release witness and retrospective. -12. Close the release and plan the next thesis. +8. Confirm the release gate is the milestone's only remaining open issue, then + repeat fresh preflight from synced `main` while the gate remains open. +9. Close the gate, run the final exact-milestone guard, reverify the unchanged + synced commit, create the signed local tag, and run the tag-specific guard. +10. Push the exact tag and let its workflow publish. +11. Verify delivery directly from workflow, GitHub Release, registry, and smoke + evidence. +12. Record the retrospective and plan the next thesis without rewriting the + published release. ## Templates Use these shapes for planned releases and heavier patch releases. -### Release Tracking Issue +### Release Gate Issue -Keep the open gate issue title/body free of the target tag/version literal while -it remains open. Record the concrete version in the release packet path, release -label, PR, and tagged source, and link issue queries instead of spelling the -target tag in the gate issue. +The gate uses the exact release version in its title and belongs to the matching +plain `vX.Y.Z` milestone. It is the final pre-tag issue, not a post-publication +evidence bucket. Close the gate issue before creating the signed local tag. ```markdown -# Release gate: current planned release +# Release gate: vX.Y.Z ## Thesis This release advances `` for `` by `
`. It focuses on `` and deliberately excludes -``, which remains in ``. +``, which remains in ``. ## Release type @@ -309,7 +332,7 @@ planned | patch | emergency | security | prerelease | docs-only - ... -## Goalposts +## Release outcomes ### 1. @@ -320,39 +343,33 @@ Issues: ## Release prep - [ ] Scope reconciled +- [ ] Every scheduled issue shares milestone `vX.Y.Z` +- [ ] Every other milestone issue is closed, moved, or cut - [ ] Version metadata updated - [ ] Changelog updated - [ ] Signposts updated - [ ] Release prep validation passed - [ ] Release-prep PR merged to main +- [ ] Fresh preflight passed from synced main while this gate remained open +- [ ] Human release checklist complete +- [ ] Close this gate before running the exact-milestone tracker-clear guard +- [ ] Create the signed local tag only from the unchanged validated commit +- [ ] Run the tag-specific guard before pushing the tag -## Publication +## Planned publication -- [ ] Signed tag created from synced main -- [ ] Release guard passed against tag -- [ ] Tag pushed -- [ ] Release Crates workflow completed -- [ ] GitHub Release visible -- [ ] crates.io visibility verified +- Signed tag: `vX.Y.Z` from synced `main` +- Workflow: Release Crates +- GitHub Release: created/finalized by the tag workflow +- Registry verification: direct crates.io lookup and CLI smoke -## Evidence +## Pre-tag evidence -Tag: Commit: Release PR: -Publish workflow: -GitHub Release: -Registry evidence: -Smoke evidence: - -## Retrospective - -- [ ] Released work recorded -- [ ] Unreleased work recorded -- [ ] Plan-versus-actual recorded -- [ ] Improvements recorded -- [ ] Fallout issues filed -- [ ] Next release thesis planned +Validation: +Release packet: +Verification plan: ``` ### Release-Prep PR Body @@ -360,6 +377,14 @@ Smoke evidence: ```markdown # release: vX.Y.Z +## Linked issue + +Tracks # + +Use a non-closing reference for the gate. The release-prep PR must not use +`Closes`, `Fixes`, or `Resolves` for it; the gate closes only after this PR +lands and final human sign-off completes. + ## Summary Prepare vX.Y.Z for release. @@ -412,13 +437,15 @@ vX.Y.Z ## Validation -- [ ] `cargo xtask release-prep-guard --version X.Y.Z` - [ ] `cargo xtask preflight` - [ ] `cargo xtask release-check` - [ ] `cargo xtask package-crates --version X.Y.Z` - [ ] docs/topics accuracy and coverage audit - [ ] CI green +The final `release-prep-guard` runs only after this PR lands and the release +gate closes; it cannot pass while that gate remains open. + ## Publish notes Manual publish required: no. Push signed tag `vX.Y.Z`; tag workflow publishes diff --git a/docs/site/roadmap.md b/docs/site/roadmap.md index 1794c270..ac016e38 100644 --- a/docs/site/roadmap.md +++ b/docs/site/roadmap.md @@ -8,13 +8,14 @@ title: Roadmap Wesley's live roadmap is on GitHub: -- [Milestones](https://github.com/flyingrobots/wesley/milestones) for - `Goalpost: ...` work groups and `Release: ...` version gates +- [Milestones](https://github.com/flyingrobots/wesley/milestones) for the sole + named-release schedule: one plain `vX.Y.Z` milestone per release - [Wesley Roadmap Project](https://github.com/users/flyingrobots/projects/18) for board views across open issues - [Issues](https://github.com/flyingrobots/wesley/issues) for work slices, release gates, raw intake, and follow-up work -- labels for lane, legend, package, work type, and status classification +- labels for triage, legend, package, work type, and status classification; + labels never schedule a named release Repository docs explain stable truth and preserve evidence: @@ -25,6 +26,8 @@ Repository docs explain stable truth and preserve evidence: - [`docs/design/README.md`](../design/README.md) for design packet evidence - [`docs/method/retro/`](../method/retro/) for closed cycle evidence -GitHub permits only one milestone per issue, so implementation slices stay in -goalpost milestones. Versioned release milestones hold release-gate issues that -link to selected goalposts. +Scheduled slices belong directly to exactly one plain version milestone and +carry no triage or concrete-version scheduling label. Unscheduled intake has +exactly one `triage:*` label and no milestone. The release gate is the final +pre-tag issue in the same version milestone; release outcomes are narrative +groupings in release packets or Project views, not scheduling authorities. diff --git a/docs/topics/README.md b/docs/topics/README.md index da032055..bd776abc 100644 --- a/docs/topics/README.md +++ b/docs/topics/README.md @@ -52,7 +52,7 @@ short path to the authoritative surface. | Make a first small PR. | [First PR](./contributing/first-pr.md) | `CONTRIBUTING.md`, GitHub Issues | | Make a first generic compiler PR. | [Plain Wesley](./plain-wesley.md#contributor-tutorial) | `docs/guides/extending.md` | | Find the right documentation path. | [Docs Orientation](./docs-orientation.md) | `docs/README.md` | -| Triage issues into release lanes. | [Issue Triage](./contributing/triage.md) | GitHub Issues, Milestones, Projects, and labels | +| Schedule or triage issues. | [Issue Triage](./contributing/triage.md) | GitHub Issues, plain version milestones, labels | | Keep docs accurate and covered. | [Docs Maintenance](./docs-maintenance.md) | `docs/governance/DOCUMENTATION_STANDARD.md` | ## Coverage Rule diff --git a/docs/topics/contributing/first-pr.md b/docs/topics/contributing/first-pr.md index 8f6f5cc1..a4fc1c2c 100644 --- a/docs/topics/contributing/first-pr.md +++ b/docs/topics/contributing/first-pr.md @@ -43,7 +43,7 @@ Use the [good first issue query](https://github.com/flyingrobots/wesley/issues?q=is%3Aissue%20state%3Aopen%20label%3A%22good%20first%20issue%22) and choose an issue that has: -- exactly one scheduling label such as `v0.3.0`, not a `triage:*` label +- exactly one plain `vX.Y.Z` milestone and no `triage:*` or version label - no `work-in-progress` label - one primary file or a very small file set - one local validation command in the acceptance criteria @@ -76,8 +76,8 @@ reviewer able to answer: issue, file, command, result. completes it. 5. Include the exact validation command and result in the PR body. -If you cannot add labels yourself, comment on the issue that you are taking it. -A maintainer should add `work-in-progress` while the slice is active. +If you cannot edit issue metadata yourself, comment on the issue that you are +taking it. A maintainer should add `work-in-progress` while the slice is active. ## Maintainer Starter-Issue Checklist @@ -88,17 +88,18 @@ context: - summary explains the task in two or three sentences - scope names one primary file or tiny file set - acceptance criteria include exactly one required local command -- labels include exactly one scheduling state: either one `triage:*` label or - one `vX.Y.Z` label -- scheduled starter issues use a `vX.Y.Z` label instead of `triage:*` and - belong to a `Goalpost: ...` milestone +- tracker metadata has exactly one scheduling state: either one `triage:*` + label and no milestone, or one plain `vX.Y.Z` milestone and no `triage:*` or + version label +- starter issues are scheduled with a plain `vX.Y.Z` milestone; narrative + release outcomes do not become additional milestones - no advanced Wesley term appears without a plain-English alias Useful commands: ```bash gh issue edit --add-label "good first issue" -gh issue edit --remove-label triage:requests --add-label v0.3.0 +gh issue edit --remove-label triage:requests --milestone v0.3.0 gh issue edit --add-label work-in-progress gh issue edit --remove-label work-in-progress ``` @@ -112,14 +113,18 @@ Use these only when maintaining the queue or preparing a release: ```bash gh issue list --label triage:requests --state open gh issue list --label "good first issue" --state open -gh issue list --label v0.3.0 --state open +gh issue list --milestone v0.3.0 --state open +# After the release-prep PR lands, while the release gate remains open: +cargo xtask preflight +# After that preflight passes, complete sign-off and close the gate: cargo xtask release-prep-guard --version X.Y.Z +# After creating the signed tag locally, but before pushing it: cargo xtask release-guard --tag vX.Y.Z ``` The full release execution layer remains -[Release Runbook](../../method/release-runbook.md). The label contract remains -[Issue Triage](./triage.md). +[Release Runbook](../../method/release-runbook.md). The scheduling contract +remains [Issue Triage](./triage.md). ## Related diff --git a/docs/topics/contributing/triage.md b/docs/topics/contributing/triage.md index 664f85a7..84f7f05d 100644 --- a/docs/topics/contributing/triage.md +++ b/docs/topics/contributing/triage.md @@ -3,17 +3,23 @@ Triage turns unscheduled intake into a named future release, a split issue, a -repo move, or a closure. Topic grouping can help the discussion, but the -decision that matters is when the work should ship. +repo move, or a closure. Classification can help the discussion, but the +decision that matters is whether the work is unscheduled or assigned to one +release. -## Label Model +## Scheduling Model -Use two label namespaces for work state: +Use exactly one of these mutually exclusive scheduling states: -| Namespace | Meaning | -| ---------- | ------------------------------------------------------------------------ | -| `triage:*` | Unscheduled intake that still needs a scheduling, closure, or move call. | -| `v*` | Scheduled work for a named future release. | +| State | Canonical GitHub Metadata | +| ----------- | ------------------------------------------------------------------------ | +| Unscheduled | Exactly one `triage:*` label and no milestone. | +| Scheduled | Exactly one plain `vX.Y.Z` milestone and no `triage:*` or version label. | + +Plain version milestones are the sole authority for scheduled release scope. +Labels classify work; they do not duplicate a scheduled issue's release. +`vX.Y.Z` denotes exact tag-form SemVer; every prerelease uses its full title, +such as `v0.3.0-alpha.2`. Current triage labels: @@ -23,34 +29,23 @@ Current triage labels: | `triage:bad-code` | Debt intake. | | `triage:cool-ideas` | Exploratory idea intake. | -Release lane labels are created only for named releases. Current named release -labels are: - -```text -v0.1.1 -v0.2.0 -v0.3.0 -v0.4.0 -v0.5.0 -``` - -Do not create generic `lane:*` labels. In particular, do not use +Do not create version labels or generic `lane:*` labels. In particular, do not use `lane:asap`, `lane:inbox`, `lane:bad-code`, `lane:cool-ideas`, `lane:release`, or `lane:planned` for active work. ## Invariants -Every open issue must have exactly one work-state label: +Every open issue must be either unscheduled or scheduled: -- either one `triage:*` label, -- or one `vX.Y.Z` label. +- Unscheduled: exactly one `triage:*` label and no milestone. +- Scheduled: exactly one plain `vX.Y.Z` milestone and no `triage:*` label. -An open issue must never have both `triage:*` and `v*`. It must never have -neither. +Scheduled issues also must not carry a concrete version label. -Implementation issues stay in `Goalpost: ...` milestones. Release milestones -hold release-gate issues only. A release-gate issue in `Release: vX.Y.Z` links -to the goalposts and issue queries that define the release. +An open issue must never carry both scheduling states, multiple release +milestones, a non-version milestone, or a version label. Narrative release +outcomes may group scope in release planning and gate issues, but they are not +additional milestones. ## Triage Flow @@ -60,24 +55,26 @@ For each issue in `triage:requests`, `triage:bad-code`, or 1. Decide whether it is valid Wesley work. 2. If it is invalid, close it or move it to the owning repo. 3. If it is valid but too broad, split it before scheduling. -4. If it fits an existing named release, replace `triage:*` with `vX.Y.Z`. +4. If it fits an existing named release, remove `triage:*` and assign the plain + `vX.Y.Z` milestone. 5. If it needs a future release that does not exist yet, propose the release - and create the release lane only after the release has a clear purpose. + and create its milestone only after the release has a clear purpose. 6. Leave it under `triage:*` only when the scheduling decision is genuinely not ready. -## Creating A Future Release Lane +## Creating A Future Release Milestone -Create a new `vX.Y.Z` label only when the proposed release has: +Create one plain `vX.Y.Z` milestone only when the proposed release has: - a clear release purpose, -- a coherent batch of issues or goalposts, +- a coherent batch of issues and narrative outcomes, - an ordering argument relative to existing releases, -- a release milestone named `Release: vX.Y.Z`, - a release-gate issue in that milestone. -The release-gate issue should link the scheduled work by GitHub query, usually -`label:vX.Y.Z`, and name any selected goalpost milestones. +The release-gate issue should link the scheduled work by milestone query and +name the narrative release outcomes that organize the scope. Do not create +`Goalpost: ...` or `Release: ...` milestones; outcomes are narrative groups +inside the plain version milestone. ## Topic Labels @@ -85,31 +82,79 @@ Topic labels are useful, but they are not scheduling labels. Use `legend:*`, `group:*`, `pkg:*`, `work:*`, `priority:*`, and ordinary work-type labels (`bug`, `feature`, `chore`, `docs`, `tests`, `ci`) to explain -what kind of work an issue represents. Use `triage:*` or `v*` to explain -where it sits in the scheduling flow. - -## Migration Notes - -The old generic lane labels are retired by this doctrine. During migration, -replace them as follows: - -| Old label | Replacement | -| ----------------- | ---------------------------------------------- | -| `lane:inbox` | `triage:requests` | -| `lane:bad-code` | `triage:bad-code` until scheduled or closed. | -| `lane:cool-ideas` | `triage:cool-ideas` until scheduled or closed. | -| `lane:release` | `vX.Y.Z` for the selected release. | -| `lane:asap` | A concrete `vX.Y.Z`, or close/split. | -| `lane:planned` | A concrete `vX.Y.Z`, or triage intake. | - -Release checks query concrete `vX.Y.Z` labels. Retired generic lane labels -are migration residue only; they are not release gates. +what kind of work an issue represents. Only `triage:*` marks unscheduled +intake; scheduled release scope comes from the issue's plain version milestone. + +## One-Time Live Cutover + +The governance change that establishes this model and the live GitHub metadata +must cross one controlled boundary. Do not merge the enforcement change while +open issues still use the retired schedule. + +After the governance pull request is approved, but immediately before it is +merged: + +1. Freeze issue scheduling, milestone edits, release-gate closure, and tag + creation for the duration of the cutover. +2. Capture the complete open-issue and milestone state with paginated, + reproducible queries: + + ```bash + gh api --hostname github.com --paginate 'repos/flyingrobots/wesley/issues?state=open&per_page=100' \ + --jq '.[] | select(.pull_request == null) | {number, labels: [.labels[].name], milestone: .milestone.title}' + gh api --hostname github.com --paginate 'repos/flyingrobots/wesley/milestones?state=all&per_page=100' \ + --jq '.[] | {number, title, state, open_issues, closed_issues}' + ``` + +3. Review and record the complete old-to-new issue mapping in the migration + issue or pull request before changing live metadata. +4. For an active `Release: vX.Y.Z` milestone with zero closed issue + associations and no colliding exact milestone, rename it to exactly + `vX.Y.Z`. If it has any closed association or the exact milestone already + exists, preserve the legacy milestone title, create or reuse the exact + milestone, move only its open issues and active gate, and then close the + emptied legacy milestone. +5. Move every open scheduled issue—including each release gate—from active + `Goalpost: ...` milestones into its reviewed exact version milestone. Never + move the closed issues that remain historical evidence in those milestones. +6. Remove `triage:*` and concrete-version labels from scheduled issues. Leave + unscheduled issues with exactly one `triage:*` label and no milestone. +7. After they contain no open issues, close the retired active narrative and + prefixed release milestones. Do not rename, delete, reopen, or rewrite closed + historical milestones or their issue associations. +8. Repeat the two snapshot queries and verify every open issue satisfies + exactly one scheduling state, every gate shares its exact version milestone, + and no retired scheduling label remains assigned to an open issue. +9. Merge enforcement only while that verification is clean, then lift the + freeze. If any mutation or verification fails, stop, leave the pull request + unmerged, and keep the release freeze in place until the cutover can be + completed and reverified. + +Retired label definitions may remain for historical search, but after cutover +they are never assigned to open issues. + +### Legacy Mapping + +Replace old generic lane and version labels as follows: + +| Old label | Replacement | +| ----------------- | --------------------------------------------------------- | +| `lane:inbox` | `triage:requests` with no milestone. | +| `lane:bad-code` | `triage:bad-code` with no milestone until scheduled. | +| `lane:cool-ideas` | `triage:cool-ideas` with no milestone until scheduled. | +| `lane:release` | The selected plain `vX.Y.Z` milestone. | +| `lane:asap` | A plain `vX.Y.Z` milestone, or close/split. | +| `lane:planned` | A plain `vX.Y.Z` milestone, or unscheduled triage intake. | +| `vX.Y.Z` label | The matching plain `vX.Y.Z` milestone; remove the label. | + +Release checks query concrete plain `vX.Y.Z` milestones. Retired lane and +version labels are historical vocabulary only; they are not release gates. ## Related Authority - [`docs/governance/labels.md`](../../governance/labels.md) defines the repository label taxonomy. - [`docs/governance/RELEASE_POLICY.md`](../../governance/RELEASE_POLICY.md) - defines release gates and version-lane behavior. + defines release gates and version-milestone behavior. - [`docs/governance/RELEASE_CHECKLIST.md`](../../governance/RELEASE_CHECKLIST.md) defines the human release sign-off items. diff --git a/docs/topics/releases.md b/docs/topics/releases.md index 183d3560..eabbf572 100644 --- a/docs/topics/releases.md +++ b/docs/topics/releases.md @@ -9,24 +9,32 @@ prepare release facts, but the tag must point at the merged `main` commit. ## Release Shape -Release state is split intentionally: - -| Surface | Purpose | -| --------------------------------------------- | -------------------------------------------- | -| GitHub label `vX.Y.Z` | Scheduled work and pre-tag blockers | -| GitHub milestone `Release: vX.Y.Z` | Release-gate and closeout issue only | -| GitHub goalpost milestones | Implementation issues | -| `CHANGELOG.md` | Historical ledger of merged behavior | -| `.continuum/release.yml` | Repo-local release profile and publish facts | -| `docs/method/releases/vX.Y.Z/release.md` | Internal release design and scope | -| `docs/method/releases/vX.Y.Z/verification.md` | Release witness after validation and publish | -| `docs/releases/vX.Y.Z.md` | User-facing release notes | - -Implementation issues stay in goalpost milestones. Release-gate issues link to -the selected goalposts and release-lane queries. The executable release guards -query version labels and exact-version references for blockers; they do not -block merely because the `Release: vX.Y.Z` gate issue remains open for -post-publication evidence. +Release state has one scheduling authority: + +| Surface | Purpose | +| --------------------------------------------- | --------------------------------------------------- | +| GitHub milestone `vX.Y.Z` | Sole schedule for release work and its pre-tag gate | +| GitHub Project fields | Priority, workflow status, and optional grouping | +| GitHub labels | Triage and classification; never release scheduling | +| `CHANGELOG.md` | Historical ledger of merged behavior | +| `.continuum/release.yml` | Repo-local release profile and publish facts | +| `docs/method/releases/vX.Y.Z/release.md` | Internal release design and scope | +| `docs/method/releases/vX.Y.Z/verification.md` | Repo-resident pre-tag validation witness | +| `docs/releases/vX.Y.Z.md` | User-facing release notes | + +Every scheduled issue, including the release gate, belongs to the one exact +plain `vX.Y.Z` milestone. Project fields and grouping labels may organize that +work, but they do not schedule it. The release gate is the final open issue in +the milestone: close it after every other issue is closed, moved, or cut, and +before creating the signed local tag. + +Here `vX.Y.Z` means exact tag-form SemVer. Every prerelease uses its full +milestone title, such as `v0.3.0-alpha.2`. + +Executable release guards query only the exact `vX.Y.Z` milestone. A missing +milestone is a hard failure, not an empty release schedule. Post-publication truth +stays with the tag workflow, finalized GitHub Release, registry records, and +direct delivery witness; it does not require a manual backfill merge. ## Required Human Checks @@ -53,10 +61,10 @@ Wesley uses the lifecycle defined in planned -> active -> release-prep -> merged -> tagged -> published -> verified -> retrospected -> closed ``` -The live tracker shape is Wesley-specific: goalpost milestones own -implementation slices, release milestones own release-gate issues, and concrete -`vX.Y.Z` labels are the version scheduling axis. This is intentional because a -GitHub issue can carry only one milestone. +The live tracker shape is deliberately small: an unscheduled issue has exactly +one `triage:*` label and no milestone; a scheduled issue has exactly one plain +`vX.Y.Z` milestone and no `triage:*` or version scheduling label. Narrative +themes are release outcomes in the release packet, not a second milestone axis. ## Pre-Release Channels @@ -75,10 +83,10 @@ pre-release, cut to unblock downstream consumers. ## Pre-Tag Launch Pass -After the release-prep PR lands on `main` but before creating the signed tag, -run one last docs/signpost audit. The goal is not to create a progress tracker; -it is to make sure the tagged commit tells the truth without a post-release -backfill. +After the release-prep PR lands on `main` but before closing the release gate +and creating the signed tag, run one last docs/signpost audit. The goal is not +to create a progress tracker; it is to make sure the tagged commit tells the +truth without a post-release backfill. Check these durable surfaces at minimum: @@ -107,14 +115,25 @@ guards are: ```bash git status --porcelain git fetch origin --tags -cargo xtask release-prep-guard --version X.Y.Z cargo xtask preflight cargo xtask release-check +# After the release-prep PR lands, while the release gate remains open: +cargo xtask preflight +# After that preflight passes, complete sign-off and close the gate: +cargo xtask release-prep-guard --version X.Y.Z +# After the signed tag exists locally, but before pushing it: cargo xtask release-guard --tag vX.Y.Z ``` -Run `release-guard` only after the signed tag exists locally and points at the -synced `main` release commit. +Run the final `release-prep-guard` only after every issue in milestone +`vX.Y.Z`, including the release gate, is closed or moved. Close the gate before +creating the tag. Run `release-guard` only after the signed tag exists locally +and points at the synced `main` release commit, and require it to pass before +the tag is pushed. On failure, resolve remote tag state first. Only when the tag +is proven absent may the unpublished local tag be deleted and the gate reopened +for pull-request corrections. If the tag is present or remote state is +indeterminate, mutate neither tag nor gate. Never delete or recreate a remote +tag. ## Related Authority diff --git a/scripts/smoke/repo-bats-prepush.sh b/scripts/smoke/repo-bats-prepush.sh index 31627ba1..371dfe67 100755 --- a/scripts/smoke/repo-bats-prepush.sh +++ b/scripts/smoke/repo-bats-prepush.sh @@ -25,6 +25,7 @@ files=( test/ir-fixtures.bats test/ci-package-manager-policy.bats test/ci-workflows.bats + test/release-governance.bats ) for f in "${files[@]}"; do diff --git a/scripts/test-ci-locally.sh b/scripts/test-ci-locally.sh index 44bbda7b..5e0bea95 100755 --- a/scripts/test-ci-locally.sh +++ b/scripts/test-ci-locally.sh @@ -39,7 +39,7 @@ export BATS_LIB_PATH=test/vendor export TERM=xterm export BATS_NO_COLOR=1 bash scripts/setup-bats-plugins.sh -bats -t test/ci-workflows.bats test/domain-empty-boundary.bats test/docs-whitespace.bats +bats -t test/ci-workflows.bats test/domain-empty-boundary.bats test/docs-whitespace.bats test/release-governance.bats echo "" echo "✅ Local CI simulation completed successfully!" diff --git a/test/README.md b/test/README.md index 344f74ed..a431d4bc 100644 --- a/test/README.md +++ b/test/README.md @@ -57,6 +57,7 @@ BATS_LIB_PATH=test/vendor \ test/docs-planning-boundary.bats \ test/domain-empty-boundary.bats \ test/ir-fixtures.bats \ + test/release-governance.bats \ test/ci-*.bats ``` diff --git a/test/ci-workflows.bats b/test/ci-workflows.bats index 72c20264..e6739baa 100644 --- a/test/ci-workflows.bats +++ b/test/ci-workflows.bats @@ -518,14 +518,35 @@ load 'vendor/bats-plugins/bats-assert/load' assert_success } -@test "release crates workflow checks version milestones and labels" { - run bash -lc "grep -F -- '--milestone' .github/workflows/release-crates.yml | wc -l" +@test "release crates workflow delegates issue checks to the release guard" { + run grep -F 'gh issue list' .github/workflows/release-crates.yml + assert_failure + + run bash -lc "grep -F 'cargo xtask release-guard --tag \"\$GITHUB_REF_NAME\"' .github/workflows/release-crates.yml | wc -l" assert_success - [ "$output" -ge 2 ] + [ "$output" -eq 2 ] +} - run bash -lc "grep -F -- '--label' .github/workflows/release-crates.yml | wc -l" +@test "release governance suite is wired into repository checks" { + run grep -F 'test/release-governance.bats' .github/workflows/ci.yml + assert_success + + run grep -F 'test/release-governance.bats' scripts/smoke/repo-bats-prepush.sh + assert_success + + run grep -F 'test/release-governance.bats' scripts/test-ci-locally.sh + assert_success +} + +@test "repo Bats checks run unconditionally" { + run grep -F 'Detect changes for repo Bats tests' .github/workflows/ci.yml + assert_failure + + run grep -F 'RUN_BATS' .github/workflows/ci.yml + assert_failure + + run grep -F 'name: Repo Bats tests (unit/docs/ci checks only)' .github/workflows/ci.yml assert_success - [ "$output" -ge 2 ] } @test "release crates workflow authenticates release guard GitHub API checks" { @@ -533,6 +554,10 @@ load 'vendor/bats-plugins/bats-assert/load' assert_success [ "$output" -ge 2 ] + run bash -lc "grep -F 'issues: read' .github/workflows/release-crates.yml | wc -l" + assert_success + [ "$output" -eq 2 ] + run bash -lc "grep -n 'name: Release guard' -A5 .github/workflows/release-crates.yml | grep -F 'GH_TOKEN: \${{ github.token }}' | wc -l" assert_success [ "$output" -eq 2 ] diff --git a/test/release-governance.bats b/test/release-governance.bats index 7e42347e..a76ff22f 100644 --- a/test/release-governance.bats +++ b/test/release-governance.bats @@ -20,7 +20,7 @@ load 'vendor/bats-plugins/bats-assert/load' run grep -F "git push origin main vX.Y.Z" docs/method/release-runbook.md assert_failure - run grep -F "Sync local main to origin/main after the release commit has landed" docs/method/release-runbook.md + run grep -F 'Sync local `main` to `origin/main` after the release commit has landed' docs/method/release-runbook.md assert_success } @@ -116,13 +116,100 @@ load 'vendor/bats-plugins/bats-assert/load' run grep -F "Autotag is not enabled" RELEASE.md assert_success - run grep -F "Release: vX.Y.Z" RELEASE.md + run grep -F "Plain \`vX.Y.Z\` milestones are the only release scheduling axis" RELEASE.md assert_success run grep -F "crates.io is the public package registry" RELEASE.md assert_success } +@test "release governance YAML is structurally valid" { + run cargo test --quiet --locked -p xtask tests::release_governance_yaml_is_structurally_valid -- --exact + assert_success + assert_output --partial "running 1 test" + assert_output --partial "1 passed; 0 failed" +} + +@test "release profile uses plain version milestones as the sole schedule" { + run grep -F "release_milestone_format: 'v{version}'" .continuum/release.yml + assert_success + + run grep -F "post_merge_pre_tag_tracker_clear: cargo xtask release-prep-guard --version {version}" .continuum/release.yml + assert_success + + run grep -Eq "^[[:space:]]*prep:" .continuum/release.yml + assert_failure + + run rg -n "release_lane_label_format|goalpost_milestone_format" .continuum/release.yml + assert_failure + + run rg -n "Goalpost:|Release:" .continuum/release.yml + assert_failure + + run grep -F "scheduled_state: 'exactly one v{version} milestone and no triage:* or concrete version label'" .continuum/release.yml + assert_success + + run grep -F "unscheduled_state: 'exactly one triage:* label and no milestone'" .continuum/release.yml + assert_success +} + +@test "issue and pull request templates preserve the scheduling invariant" { + run grep -F 'Linked issue is scheduled in one plain `vX.Y.Z` milestone and has no `triage:*` or version label.' .github/pull_request_template.md + assert_success + + run grep -F 'Linked issue is either unscheduled' .github/pull_request_template.md + assert_failure +} + +@test "triage doctrine defines mutually exclusive scheduled states" { + run grep -F 'Unscheduled: exactly one `triage:*` label and no milestone.' docs/topics/contributing/triage.md + assert_success + + run grep -F 'Scheduled: exactly one plain `vX.Y.Z` milestone and no `triage:*` label.' docs/topics/contributing/triage.md + assert_success +} + +@test "triage doctrine requires an atomic live tracker cutover" { + run grep -F "After the governance pull request is approved, but immediately before it is" docs/topics/contributing/triage.md + assert_success + + run grep -F "Freeze issue scheduling, milestone edits, release-gate closure, and tag" docs/topics/contributing/triage.md + assert_success + + run grep -F "gh api --hostname github.com --paginate 'repos/flyingrobots/wesley/issues?state=open&per_page=100'" docs/topics/contributing/triage.md + assert_success + + run grep -F "milestones?state=all&per_page=100" docs/topics/contributing/triage.md + assert_success + + run grep -F -- "--jq '.[] | {number, title, state, open_issues, closed_issues}'" docs/topics/contributing/triage.md + assert_success + + run grep -F "For an active \`Release: vX.Y.Z\` milestone with zero closed issue" docs/topics/contributing/triage.md + assert_success + + run grep -F "If it has any closed association or the exact milestone already" docs/topics/contributing/triage.md + assert_success + + run grep -F "preserve the legacy milestone title, create or reuse the exact" docs/topics/contributing/triage.md + assert_success + + run grep -F "Move every open scheduled issue—including each release gate—from active" docs/topics/contributing/triage.md + assert_success + + run grep -F "Remove \`triage:*\` and concrete-version labels from scheduled issues" docs/topics/contributing/triage.md + assert_success + + run grep -F "Do not rename, delete, reopen, or rewrite closed" docs/topics/contributing/triage.md + assert_success + + run rg -U 'Never\s+move the closed issues that remain historical evidence' docs/topics/contributing/triage.md + assert_success + + run grep -F "Merge enforcement only while that verification is clean" docs/topics/contributing/triage.md + assert_success +} + @test "release doctrine lists profile user-doc signposts" { run grep -F '`docs/site/`' docs/method/release.md assert_success @@ -154,21 +241,110 @@ load 'vendor/bats-plugins/bats-assert/load' run grep -F "workflow source checked into the tag is wrong" docs/method/release-runbook.md assert_success - run grep -F "Same-tag reruns are for credentials, permissions" docs/method/release-runbook.md + run grep -F "Same-tag reruns are allowed only when the tagged source is correct" docs/method/release-runbook.md assert_success - run grep -F "GitHub Release/API problems" docs/method/release-runbook.md + run grep -F "Release/API delivery" docs/method/release-runbook.md assert_success } -@test "release gate issue template avoids exact version blocker tokens" { +@test "release gate issue template uses the version milestone directly" { run grep -F "# Release gate: vX.Y.Z" docs/method/release.md + assert_success + + run grep -F "Keep the open gate issue title/body free of the target tag/version literal" docs/method/release.md assert_failure - run grep -F "# Release gate: current planned release" docs/method/release.md + run grep -F "Close the gate issue before creating the signed local tag" docs/method/release.md + assert_success +} + +@test "release prep PR tracks the gate without auto-closing it" { + run grep -F 'Tracks #' docs/method/release.md assert_success - run grep -F "Keep the open gate issue title/body free of the target tag/version literal" docs/method/release.md + run grep -F 'The release-prep PR must not use' docs/method/release.md + assert_success + + run grep -F 'The final `release-prep-guard` runs only after this PR lands' docs/method/release.md + assert_success +} + +@test "release runbook orders pre-tag and tag-specific guards" { + run awk ' + /Run a fresh `cargo xtask preflight`/{preflight=NR} + /Complete the human checklist and close the release-gate issue/{gate=NR} + /Run `cargo xtask release-prep-guard/{prep=NR} + /Fetch `origin` again/{refresh=NR} + /`HEAD` still equals both the recorded/{unchanged=NR} + /Create the release tag locally/{tag=NR} + /Run `cargo xtask release-guard/{tagged=NR} + /Push the exact release tag only/{push=NR} + /Monitor the tag-triggered workflow/{workflow=NR} + END { exit !(preflight < gate && gate < prep && prep < refresh && refresh < unchanged && unchanged < tag && tag < tagged && tagged < push && push < workflow) } + ' docs/method/release-runbook.md + assert_success + + run bash -lc "grep -F 'cargo xtask release-prep-guard --version X.Y.Z' docs/method/release-runbook.md | wc -l" + assert_success + [ "$output" -eq 2 ] + + run grep -F "run while the release gate remains open" docs/method/release-runbook.md + assert_success + + run grep -F "git ls-remote --exit-code --tags origin refs/tags/vX.Y.Z" docs/method/release-runbook.md + assert_success + + run grep -F "If the tag is present remotely, do not delete the local tag and do not reopen" docs/method/release-runbook.md + assert_success + + run grep -F "If remote state is indeterminate, change neither the tag nor the gate" docs/method/release-runbook.md + assert_success + + run grep -F "Only when the tag is proven absent may local recovery continue" docs/method/release-runbook.md + assert_success + + run grep -F "delete only that unpublished tag" docs/method/release-runbook.md + assert_success + + run grep -F "Reopen the release gate" docs/method/release-runbook.md + assert_success + + run grep -F "must never be deleted, moved, or recreated" docs/method/release-runbook.md + assert_success + + run grep -F "Do not create or finalize a competing release manually" docs/method/release-runbook.md + assert_success + + run grep -F "Manual recovery must never create," docs/method/release-runbook.md + assert_success + + run grep -F "rerun the same tag" docs/method/release-runbook.md + assert_success + + run rg -n "Create or verify the GitHub Release|Create the GitHub Release|Recreate or update the GitHub Release" docs/method/release-runbook.md docs/CRATES_IO_RELEASE.md + assert_failure + + run grep -F "Verify that the tag workflow creates its draft GitHub Release" docs/CRATES_IO_RELEASE.md + assert_success +} + +@test "root release path tags only the validated synced main commit" { + run awk ' + /git merge --ff-only origin\/main/{sync=NR} + /validated_head=.*git rev-parse HEAD/{record=NR} + /cargo xtask preflight/{preflight=NR} + /# Only now: complete human sign-off and close the release gate/{gate=NR} + /cargo xtask release-prep-guard/{clear=NR} + /git fetch origin --tags --prune/{refresh=NR} + /test .*git rev-parse HEAD.*validated_head/{unchanged=NR} + /test .*git rev-parse HEAD.*git rev-parse origin\/main/{synced=NR} + /git tag -s vX.Y.Z/{tag=NR} + END { exit !(sync < record && record < preflight && preflight < gate && gate < clear && clear < refresh && refresh < unchanged && unchanged < synced && synced < tag) } + ' RELEASE.md + assert_success + + run grep -F "# Only now: complete human sign-off and close the release gate." RELEASE.md assert_success } diff --git a/xtask/Cargo.toml b/xtask/Cargo.toml index 5ce26895..a1ab8eb1 100644 --- a/xtask/Cargo.toml +++ b/xtask/Cargo.toml @@ -11,3 +11,6 @@ semver = "1.0" serde_json = "1.0" tokio = { version = "1", features = ["rt", "time"] } toml = "0.8" + +[dev-dependencies] +yaml-rust2 = { version = "0.11", default-features = false } diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 43706659..bf51767f 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -2,7 +2,6 @@ use ninelives::{Backoff, Jitter, ResilienceError, RetryPolicy}; use semver::Version; -use std::collections::BTreeMap; use std::env; use std::ffi::OsString; use std::fs; @@ -14,6 +13,7 @@ const EXIT_OK: u8 = 0; const EXIT_FAILURE: u8 = 1; const EXIT_USAGE: u8 = 2; const ALPHA_VERSION: &str = "0.0.1"; +const RELEASE_GITHUB_HOST: &str = "github.com"; const FORBIDDEN_GIT_IDENTITIES: &[&str] = &[ "Wesley Tests", "wesley-tests@example.com", @@ -822,7 +822,7 @@ fn run_release_prep_guard(args: &[OsString]) -> Result<(), Error> { check_git_identity_guard()?; check_publish_manifest_versions(&options.version)?; check_release_required_files(&options.version)?; - check_release_tracker_clear(&tag, &options.version)?; + check_release_tracker_clear(&tag)?; check_package_file_sets()?; println!("xtask: release prep guard passed for {}", options.version); Ok(()) @@ -836,7 +836,7 @@ fn run_release_guard_for_tag(tag: &str) -> Result<(), Error> { assert_clean_worktree()?; check_publish_manifest_versions(&version)?; check_release_required_files(&version)?; - check_release_tracker_clear(tag, &version)?; + check_release_tracker_clear(tag)?; check_readme_version_headline(&version)?; check_technical_teardown_version(&version)?; check_no_wip_fixup_commits(tag)?; @@ -1405,8 +1405,8 @@ fn is_leap_year(year: u16) -> bool { (year % 4 == 0 && year % 100 != 0) || year % 400 == 0 } -fn check_release_tracker_clear(tag: &str, version: &str) -> Result<(), Error> { - check_release_issue_tracker_clear(tag, version) +fn check_release_tracker_clear(tag: &str) -> Result<(), Error> { + check_release_issue_tracker_clear(tag) } fn check_readme_version_headline(version: &str) -> Result<(), Error> { @@ -1830,184 +1830,119 @@ fn check_cargo_doc_clean() -> Result<(), Error> { } } -fn check_release_issue_tracker_clear(tag: &str, version: &str) -> Result<(), Error> { - let repo = release_github_repository()?; - let queries = release_issue_query_specs(tag, version, &repo); - let mut matches = BTreeMap::new(); +struct GhCommandOutput { + success: bool, + stdout: String, + stderr: String, +} - let text_query = release_issue_title_body_query(&repo); - let text_output = Command::new("gh") - .args(&text_query) - .output() - .map_err(|source| { - Error::Usage(format!( - "failed to spawn `gh {}` for release issue tracker check: {source}", - text_query.join(" ") - )) - })?; +fn run_gh_command(query: &[String]) -> Result { + let output = Command::new("gh").args(query).output()?; + Ok(GhCommandOutput { + success: output.status.success(), + stdout: String::from_utf8_lossy(&output.stdout).into_owned(), + stderr: String::from_utf8_lossy(&output.stderr).into_owned(), + }) +} + +fn run_release_tracker_query(query: &[String], run_gh: &mut R) -> Result +where + R: FnMut(&[String]) -> Result, +{ + let output = run_gh(query).map_err(|source| { + Error::Usage(format!( + "failed to spawn `gh {}` for release issue tracker check: {source}", + query.join(" ") + )) + })?; - if !text_output.status.success() { - let stderr = String::from_utf8_lossy(&text_output.stderr); + if !output.success { return Err(Error::CheckFailed { check: "release issue tracker".to_string(), failures: vec![format!( "`gh {}` failed: {}", - text_query.join(" "), - stderr.trim() + query.join(" "), + output.stderr.trim() )], }); } - let text_stdout = String::from_utf8_lossy(&text_output.stdout); - for issue in parse_current_version_issue_text(&text_stdout, tag, version)? { - matches.entry(issue.key).or_insert(issue.display); - } - - for query in queries { - let output = Command::new("gh") - .args(&query.args) - .output() - .map_err(|source| { - Error::Usage(format!( - "failed to spawn `gh {}` for release issue tracker check: {source}", - query.args.join(" ") - )) - })?; - - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - if query.ignore_missing_selector && is_missing_issue_selector_error(&stderr) { - continue; - } - return Err(Error::CheckFailed { - check: "release issue tracker".to_string(), - failures: vec![format!( - "`gh {}` failed: {}", - query.args.join(" "), - stderr.trim() - )], - }); - } - - let stdout = String::from_utf8_lossy(&output.stdout); - for issue in parse_release_issue_list(&stdout, &query.source)? { - matches.entry(issue.key).or_insert(issue.display); - } - } - - let prior_output = Command::new("gh") - .args([ - "issue", - "list", - "--repo", - &repo, - "--state", - "open", - "--limit", - "1000", - "--json", - "number,title,url,labels,milestone", - ]) - .output() - .map_err(|source| { - Error::Usage(format!( - "failed to spawn `gh issue list --repo {repo}` for prior-version issue check: {source}" - )) - })?; + Ok(output.stdout) +} - if !prior_output.status.success() { - let stderr = String::from_utf8_lossy(&prior_output.stderr); - return Err(Error::CheckFailed { +fn check_release_milestone_exists(tag: &str, repo: &str, run_gh: &mut R) -> Result<(), Error> +where + R: FnMut(&[String]) -> Result, +{ + let query = release_milestone_query(repo); + let stdout = run_release_tracker_query(&query, run_gh)?; + if release_milestone_exists(&stdout, tag) { + Ok(()) + } else { + Err(Error::CheckFailed { check: "release issue tracker".to_string(), failures: vec![format!( - "`gh issue list --repo {repo}` failed: {}", - stderr.trim() + "required open version milestone `{tag}` does not exist in `{repo}`" )], - }); - } - - let prior_stdout = String::from_utf8_lossy(&prior_output.stdout); - for issue in parse_prior_version_issue_lanes(&prior_stdout, version)? { - matches.entry(issue.key).or_insert(issue.display); + }) } - - finish_check( - "release issue tracker", - matches.into_values().collect::>(), - ) } -struct ReleaseIssueQuery { - args: Vec, - source: String, - ignore_missing_selector: bool, +fn check_release_issue_tracker_clear(tag: &str) -> Result<(), Error> { + let repo = release_github_repository()?; + check_release_issue_tracker_clear_with(tag, &repo, &mut run_gh_command) } -struct ReleaseIssueMatch { - key: String, - display: String, -} +fn check_release_issue_tracker_clear_with( + tag: &str, + repo: &str, + run_gh: &mut R, +) -> Result<(), Error> +where + R: FnMut(&[String]) -> Result, +{ + check_release_milestone_exists(tag, repo, run_gh)?; + let query = release_milestone_issue_query(tag, repo); + let stdout = run_release_tracker_query(&query, run_gh)?; -#[cfg(test)] -fn release_issue_queries(tag: &str, version: &str, repo: &str) -> Vec> { - std::iter::once(release_issue_title_body_query(repo)) - .chain( - release_issue_query_specs(tag, version, repo) - .into_iter() - .map(|query| query.args), - ) - .collect() + let matches = parse_release_issue_list(&stdout, &format!("version milestone `{tag}`"))?; + finish_check("release issue tracker", matches) } -fn release_issue_query_specs(tag: &str, version: &str, _repo: &str) -> Vec { +fn release_milestone_query(repo: &str) -> Vec { vec![ - ReleaseIssueQuery { - args: release_issue_selector_query("--label", tag), - source: format!("release label `{tag}`"), - ignore_missing_selector: true, - }, - ReleaseIssueQuery { - args: release_issue_selector_query("--label", tag), - source: format!("label `{tag}`"), - ignore_missing_selector: true, - }, - ReleaseIssueQuery { - args: release_issue_selector_query("--label", version), - source: format!("label `{version}`"), - ignore_missing_selector: true, - }, + "api".to_string(), + "--hostname".to_string(), + RELEASE_GITHUB_HOST.to_string(), + "--paginate".to_string(), + format!("repos/{repo}/milestones?state=open&per_page=100"), + "--jq".to_string(), + ".[].title".to_string(), ] } -fn release_issue_title_body_query(repo: &str) -> Vec { +fn release_milestone_exists(content: &str, target: &str) -> bool { + content.lines().any(|title| title == target) +} + +fn release_milestone_issue_query(tag: &str, repo: &str) -> Vec { vec![ "issue".to_string(), "list".to_string(), "--repo".to_string(), - repo.to_string(), + format!("{RELEASE_GITHUB_HOST}/{repo}"), "--state".to_string(), "open".to_string(), + "--milestone".to_string(), + tag.to_string(), "--limit".to_string(), "1000".to_string(), "--json".to_string(), - "number,title,url,body".to_string(), - ] -} - -fn release_issue_selector_query(selector: &str, value: &str) -> Vec { - vec![ - "issue".to_string(), - "list".to_string(), - "--state".to_string(), - "open".to_string(), - selector.to_string(), - value.to_string(), - "--json".to_string(), "number,title,url".to_string(), ] } -fn parse_release_issue_list(content: &str, source: &str) -> Result, Error> { +fn parse_release_issue_list(content: &str, source: &str) -> Result, Error> { let issues = serde_json::from_str::(content).map_err(|source| { Error::Usage(format!( "failed to parse release issue tracker output as JSON: {source}" @@ -2039,221 +1974,83 @@ fn parse_release_issue_list(content: &str, source: &str) -> Result Result, Error> { - let issues = serde_json::from_str::(content).map_err(|source| { - Error::Usage(format!( - "failed to parse release issue tracker title/body output as JSON: {source}" - )) - })?; - let Some(issues) = issues.as_array() else { - return Err(Error::Usage( - "release issue tracker title/body output must be a JSON array".to_string(), - )); - }; - - let mut matches = Vec::new(); - for issue in issues { - let number = issue - .get("number") - .and_then(serde_json::Value::as_u64) - .ok_or_else(|| { - Error::Usage( - "release issue tracker title/body issue is missing numeric `number`" - .to_string(), - ) - })?; - let title = issue - .get("title") - .and_then(serde_json::Value::as_str) - .ok_or_else(|| { - Error::Usage( - "release issue tracker title/body issue is missing string `title`".to_string(), - ) - })?; - let url = issue - .get("url") - .and_then(serde_json::Value::as_str) - .ok_or_else(|| { - Error::Usage( - "release issue tracker title/body issue is missing string `url`".to_string(), - ) - })?; - let body = issue - .get("body") - .and_then(serde_json::Value::as_str) - .unwrap_or(""); - - if contains_exact_release_reference(title, tag, version) - || contains_exact_release_reference(body, tag, version) - { - matches.push(ReleaseIssueMatch { - key: url.to_string(), - display: format!("#{number} {title} {url} (title/body text)"), - }); +fn release_github_repository() -> Result { + let ambient = match env::var("GITHUB_REPOSITORY") { + Ok(repository) => Some(repository), + Err(env::VarError::NotPresent) => None, + Err(env::VarError::NotUnicode(_)) => { + return Err(Error::Usage( + "GITHUB_REPOSITORY must be valid UTF-8".to_string(), + )); } - } - Ok(matches) -} + }; -fn contains_exact_release_reference(content: &str, tag: &str, version: &str) -> bool { - contains_exact_release_token(content, tag) || contains_exact_release_token(content, version) + release_github_repository_with(ambient.as_deref(), &mut git_output) } -fn contains_exact_release_token(content: &str, needle: &str) -> bool { - let mut search = content; - while let Some(pos) = search.find(needle) { - let before = search[..pos].chars().next_back(); - let after = &search[pos + needle.len()..]; - if is_release_version_boundary(before) && is_release_issue_token_end_boundary(after) { - return true; - } - search = &search[pos + 1..]; - } - false -} +fn release_github_repository_with( + ambient_repository: Option<&str>, + git: &mut G, +) -> Result +where + G: FnMut(&[&str]) -> Result, +{ + let fetch_remote = git(&["remote", "get-url", "origin"])?; + let push_remotes = git(&["remote", "get-url", "--push", "--all", "origin"])?; -fn is_release_issue_token_end_boundary(after: &str) -> bool { - let mut chars = after.chars(); - match chars.next() { - None => true, - Some('.') => match chars.next() { - None => true, - Some(c) => c.is_whitespace(), - }, - Some(c) => !c.is_ascii_alphanumeric() && c != '-' && c != '+' && c != '_', - } + resolve_release_github_repository(&fetch_remote, &push_remotes, ambient_repository) + .map_err(Error::Usage) } -fn parse_prior_version_issue_lanes( - content: &str, - current_version: &str, -) -> Result, Error> { - let current = Version::parse(current_version).map_err(|source| { - Error::Usage(format!( - "failed to parse current release version `{current_version}` for prior-version issue check: {source}" - )) - })?; - let issues = serde_json::from_str::(content).map_err(|source| { - Error::Usage(format!( - "failed to parse prior-version issue tracker output as JSON: {source}" - )) - })?; - let Some(issues) = issues.as_array() else { - return Err(Error::Usage( - "prior-version issue tracker output must be a JSON array".to_string(), - )); - }; - - let mut matches = Vec::new(); - for issue in issues { - let number = issue - .get("number") - .and_then(serde_json::Value::as_u64) - .ok_or_else(|| { - Error::Usage( - "prior-version issue tracker issue is missing numeric `number`".to_string(), - ) - })?; - let title = issue - .get("title") - .and_then(serde_json::Value::as_str) - .ok_or_else(|| { - Error::Usage( - "prior-version issue tracker issue is missing string `title`".to_string(), - ) - })?; - let url = issue - .get("url") - .and_then(serde_json::Value::as_str) - .ok_or_else(|| { - Error::Usage( - "prior-version issue tracker issue is missing string `url`".to_string(), - ) - })?; - - let mut lanes = Vec::new(); - if let Some(title) = issue - .get("milestone") - .and_then(|milestone| milestone.get("title")) - .and_then(serde_json::Value::as_str) - { - if version_lane_is_prior(title, ¤t) { - lanes.push(format!("milestone `{title}`")); - } - } - - if let Some(labels) = issue.get("labels").and_then(serde_json::Value::as_array) { - for label in labels { - if let Some(name) = label.get("name").and_then(serde_json::Value::as_str) { - if version_lane_is_prior(name, ¤t) { - lanes.push(format!("label `{name}`")); - } - } - } - } +fn resolve_release_github_repository( + origin_fetch_remote: &str, + origin_push_remotes: &str, + ambient_repository: Option<&str>, +) -> Result { + let origin_repository = + parse_github_repository_remote(origin_fetch_remote).ok_or_else(|| { + format!( + "could not infer GitHub repository from origin fetch URL `{origin_fetch_remote}`" + ) + })?; - if !lanes.is_empty() { - matches.push(ReleaseIssueMatch { - key: url.to_string(), - display: format!( - "#{number} {title} {url} (prior version {})", - lanes.join(", ") - ), - }); - } + let push_remotes = origin_push_remotes + .lines() + .map(str::trim) + .filter(|remote| !remote.is_empty()) + .collect::>(); + if push_remotes.is_empty() { + return Err("origin has no effective push URL".to_string()); } - - Ok(matches) -} - -fn version_lane_is_prior(lane: &str, current: &Version) -> bool { - version_from_lane_name(lane).is_some_and(|version| version < *current) -} - -fn version_from_lane_name(lane: &str) -> Option { - let lane = lane.trim(); - let version = lane.strip_prefix('v').unwrap_or(lane); - let parsed = Version::parse(version).ok()?; - if parsed.build.is_empty() { - Some(parsed) - } else { - None + for push_remote in push_remotes { + let push_repository = parse_github_repository_remote(push_remote).ok_or_else(|| { + format!("could not infer GitHub repository from origin push URL `{push_remote}`") + })?; + if push_repository != origin_repository { + return Err(format!( + "origin push repository `{push_repository}` does not match origin fetch repository `{origin_repository}`" + )); + } } -} - -fn is_missing_issue_selector_error(stderr: &str) -> bool { - let stderr = stderr.to_ascii_lowercase(); - stderr.contains("could not resolve") - || stderr.contains("not found") - || stderr.contains("no milestone") - || stderr.contains("no label") -} -fn release_github_repository() -> Result { - if let Ok(repository) = env::var("GITHUB_REPOSITORY") { - if parse_github_repository_path(&repository).is_some() { - return Ok(repository); + if let Some(repository) = ambient_repository { + let repository = parse_github_repository_path(repository).ok_or_else(|| { + format!("GITHUB_REPOSITORY `{repository}` is not a valid owner/repository path") + })?; + if repository != origin_repository { + return Err(format!( + "GITHUB_REPOSITORY `{repository}` does not match origin fetch and push repository `{origin_repository}`" + )); } } - let remote = git_output(&["remote", "get-url", "origin"])?; - parse_github_repository_remote(&remote).ok_or_else(|| { - Error::Usage(format!( - "could not infer GitHub repository from origin remote `{remote}`" - )) - }) + Ok(origin_repository) } fn parse_github_repository_remote(remote: &str) -> Option { @@ -3159,7 +2956,7 @@ Commands: package-crates Check package file sets for the crates.io release set publish-alpha Publish the crates.io alpha package set; dry-run by default publish-crates Publish crates.io package set for a release tag - release-prep-guard Verify release prep before a tag exists + release-prep-guard Verify the post-merge tracker is clear before local tagging release-guard Verify that a release tag is eligible to publish release-check Run strict preflight, then build and package release artifacts legacy-preflight Run the historical pnpm package preflight for legacy changes @@ -3533,11 +3330,118 @@ enum Error { #[cfg(test)] mod tests { use super::*; + use std::collections::VecDeque; + use yaml_rust2::{Yaml, YamlLoader}; fn os_args(args: &[&str]) -> Vec { args.iter().map(OsString::from).collect() } + fn string_args(args: &[&str]) -> Vec { + args.iter().map(|arg| (*arg).to_string()).collect() + } + + fn gh_success(stdout: &str) -> GhCommandOutput { + GhCommandOutput { + success: true, + stdout: stdout.to_string(), + stderr: String::new(), + } + } + + fn gh_failure(stderr: &str) -> GhCommandOutput { + GhCommandOutput { + success: false, + stdout: String::new(), + stderr: stderr.to_string(), + } + } + + fn exercise_release_tracker_guard( + responses: Vec>, + ) -> (Result<(), Error>, Vec>, usize) { + let mut responses = VecDeque::from(responses); + let mut calls = Vec::new(); + let result = { + let mut fake_gh = |args: &[String]| { + calls.push(args.to_vec()); + responses + .pop_front() + .expect("release guard made an unexpected gh call") + }; + check_release_issue_tracker_clear_with("v1.2.3", "flyingrobots/wesley", &mut fake_gh) + }; + + (result, calls, responses.len()) + } + + fn exercise_release_repository_discovery( + ambient_repository: Option<&str>, + responses: Vec<&str>, + ) -> (Result, Vec>, usize) { + let mut responses = VecDeque::from(responses); + let mut calls = Vec::new(); + let result = { + let mut fake_git = |args: &[&str]| { + calls.push(string_args(args)); + Ok(responses + .pop_front() + .expect("release repository discovery made an unexpected git call") + .to_string()) + }; + release_github_repository_with(ambient_repository, &mut fake_git) + }; + + (result, calls, responses.len()) + } + + fn expected_release_tracker_calls() -> Vec> { + vec![ + string_args(&[ + "api", + "--hostname", + "github.com", + "--paginate", + "repos/flyingrobots/wesley/milestones?state=open&per_page=100", + "--jq", + ".[].title", + ]), + string_args(&[ + "issue", + "list", + "--repo", + "github.com/flyingrobots/wesley", + "--state", + "open", + "--milestone", + "v1.2.3", + "--limit", + "1000", + "--json", + "number,title,url", + ]), + ] + } + + fn parse_yaml_document(name: &str, source: &str) -> Yaml { + let documents = YamlLoader::load_from_str(source) + .unwrap_or_else(|error| panic!("{name} should parse as YAML: {error}")); + assert_eq!( + documents.len(), + 1, + "{name} should contain one YAML document" + ); + documents.into_iter().next().unwrap() + } + + fn yaml_field<'a>(value: &'a Yaml, key: &str) -> Option<&'a Yaml> { + value.as_hash()?.get(&Yaml::String(key.to_string())) + } + + fn is_concrete_version_label(label: &str) -> bool { + Version::parse(label.strip_prefix('v').unwrap_or(label)).is_ok() + } + #[test] fn bench_ir_options_use_advisory_defaults() { assert_eq!( @@ -3697,122 +3601,474 @@ mod tests { } #[test] - fn release_guard_queries_github_issue_tracker() { - let queries = release_issue_queries("v1.2.3", "1.2.3", "flyingrobots/wesley"); + fn release_governance_yaml_is_structurally_valid() { + let profile = parse_yaml_document( + ".continuum/release.yml", + include_str!("../../.continuum/release.yml"), + ); + let versioning = yaml_field(&profile, "versioning").expect("versioning should exist"); + assert_eq!( + yaml_field(versioning, "release_milestone_format").and_then(Yaml::as_str), + Some("v{version}") + ); + assert!(yaml_field(versioning, "release_lane_label_format").is_none()); + assert!(yaml_field(versioning, "goalpost_milestone_format").is_none()); + + let validation = yaml_field(&profile, "validation").expect("validation should exist"); + assert!(yaml_field(validation, "prep").is_none()); + assert_eq!( + yaml_field(validation, "post_merge_pre_tag_tracker_clear").and_then(Yaml::as_str), + Some("cargo xtask release-prep-guard --version {version}") + ); + + let issue_model = yaml_field(&profile, "issue_model").expect("issue_model should exist"); + assert_eq!( + yaml_field(issue_model, "scheduled_state").and_then(Yaml::as_str), + Some("exactly one v{version} milestone and no triage:* or concrete version label") + ); + assert_eq!( + yaml_field(issue_model, "unscheduled_state").and_then(Yaml::as_str), + Some("exactly one triage:* label and no milestone") + ); + + let issue_forms = [ + ( + ".github/ISSUE_TEMPLATE/bug.yml", + include_str!("../../.github/ISSUE_TEMPLATE/bug.yml"), + ), + ( + ".github/ISSUE_TEMPLATE/chore.yml", + include_str!("../../.github/ISSUE_TEMPLATE/chore.yml"), + ), + ( + ".github/ISSUE_TEMPLATE/feature.yml", + include_str!("../../.github/ISSUE_TEMPLATE/feature.yml"), + ), + ( + ".github/ISSUE_TEMPLATE/rfc.yml", + include_str!("../../.github/ISSUE_TEMPLATE/rfc.yml"), + ), + ]; + + for (name, source) in issue_forms { + let form = parse_yaml_document(name, source); + let labels = yaml_field(&form, "labels") + .and_then(Yaml::as_vec) + .unwrap_or_else(|| panic!("{name} labels should be a YAML sequence")); + let labels = labels + .iter() + .map(|label| { + label + .as_str() + .unwrap_or_else(|| panic!("{name} labels should be strings")) + }) + .collect::>(); + + assert_eq!( + labels + .iter() + .filter(|label| label.starts_with("triage:")) + .count(), + 1, + "{name} should declare exactly one triage label" + ); + assert!( + labels.iter().all(|label| !is_concrete_version_label(label)), + "{name} should not declare a concrete version label" + ); + assert!( + yaml_field(&form, "milestone").is_none(), + "{name} should not preassign a milestone" + ); + } + } + + #[test] + fn concrete_version_label_detection_covers_bare_and_v_prefixed_semver() { + for label in [ + "1.2.3", + "v1.2.3", + "1.2.3-alpha.2", + "v1.2.3-alpha.2", + "1.2.3+build.4", + "v1.2.3+build.4", + ] { + assert!( + is_concrete_version_label(label), + "{label} should be recognized as a concrete version label" + ); + } + for label in ["version", "v1.2", "release:v1.2.3", "triage:requests"] { + assert!( + !is_concrete_version_label(label), + "{label} should remain a classification label" + ); + } + } + + #[test] + fn release_milestone_issue_query_is_exact_and_stable() { assert_eq!( - queries, + release_milestone_issue_query("v1.2.3", "flyingrobots/wesley"), vec![ - vec![ - "issue", - "list", - "--repo", - "flyingrobots/wesley", - "--state", - "open", - "--limit", - "1000", - "--json", - "number,title,url,body", - ], - vec![ - "issue", - "list", - "--state", - "open", - "--label", - "v1.2.3", - "--json", - "number,title,url", - ], - vec![ - "issue", - "list", - "--state", - "open", - "--label", - "v1.2.3", - "--json", - "number,title,url", - ], - vec![ - "issue", - "list", - "--state", - "open", - "--label", - "1.2.3", - "--json", - "number,title,url", - ], + "issue", + "list", + "--repo", + "github.com/flyingrobots/wesley", + "--state", + "open", + "--milestone", + "v1.2.3", + "--limit", + "1000", + "--json", + "number,title,url", ] ); } #[test] - fn release_guard_does_not_query_release_gate_milestones() { - let queries = release_issue_queries("v1.2.3", "1.2.3", "flyingrobots/wesley"); + fn release_guard_orchestrates_exact_milestone_queries_only() { + let (result, calls, remaining_responses) = exercise_release_tracker_guard(vec![ + Ok(gh_success("v1.2.2\nv1.2.3\nv1.2.30\n")), + Ok(gh_success("[]")), + ]); - assert!( - queries - .iter() - .all(|query| !query.iter().any(|arg| arg == "--milestone")), - "release-gate milestones may stay open until post-publication closeout" + assert!(result.is_ok(), "{result:?}"); + assert_eq!(calls, expected_release_tracker_calls()); + assert_eq!(remaining_responses, 0); + } + + #[test] + fn release_guard_rejects_missing_target_milestone_before_issue_query() { + let (result, calls, remaining_responses) = + exercise_release_tracker_guard(vec![Ok(gh_success(""))]); + + assert!(matches!( + result, + Err(Error::CheckFailed { check, failures }) + if check == "release issue tracker" + && failures == vec![ + "required open version milestone `v1.2.3` does not exist in `flyingrobots/wesley`" + ] + )); + assert_eq!(calls, &expected_release_tracker_calls()[..1]); + assert_eq!(remaining_responses, 0); + } + + #[test] + fn release_guard_rejects_closed_target_milestone_before_issue_query() { + // Closed milestones are intentionally absent from the open-milestone API + // response, so they are observationally identical to missing milestones. + let (result, calls, remaining_responses) = + exercise_release_tracker_guard(vec![Ok(gh_success("v1.2.2\nv1.2.30\n"))]); + + assert!(matches!(result, Err(Error::CheckFailed { .. }))); + assert_eq!(calls, &expected_release_tracker_calls()[..1]); + assert_eq!(remaining_responses, 0); + } + + #[test] + fn release_guard_reports_open_target_milestone_issue() { + let (result, calls, remaining_responses) = exercise_release_tracker_guard(vec![ + Ok(gh_success("v1.2.3\n")), + Ok(gh_success( + r#"[{"number":17,"title":"Finish release proof","url":"https://example.test/issues/17"}]"#, + )), + ]); + + assert!(matches!( + result, + Err(Error::CheckFailed { check, failures }) + if check == "release issue tracker" + && failures == vec![ + "#17 Finish release proof https://example.test/issues/17 (version milestone `v1.2.3`)" + ] + )); + assert_eq!(calls, expected_release_tracker_calls()); + assert_eq!(remaining_responses, 0); + } + + #[test] + fn release_guard_reports_all_returned_issues_in_number_order() { + let (result, calls, remaining_responses) = exercise_release_tracker_guard(vec![ + Ok(gh_success("v1.2.3\n")), + Ok(gh_success( + r#"[ + {"number":30,"title":"Third","url":"https://example.test/issues/30"}, + {"number":2,"title":"First","url":"https://example.test/issues/2"}, + {"number":17,"title":"Second","url":"https://example.test/issues/17"} + ]"#, + )), + ]); + + assert!(matches!( + result, + Err(Error::CheckFailed { failures, .. }) + if failures == vec![ + "#2 First https://example.test/issues/2 (version milestone `v1.2.3`)", + "#17 Second https://example.test/issues/17 (version milestone `v1.2.3`)", + "#30 Third https://example.test/issues/30 (version milestone `v1.2.3`)", + ] + )); + assert_eq!(calls, expected_release_tracker_calls()); + assert_eq!(remaining_responses, 0); + } + + #[test] + fn release_guard_rejects_malformed_issue_response() { + let (result, calls, remaining_responses) = + exercise_release_tracker_guard(vec![Ok(gh_success("v1.2.3\n")), Ok(gh_success("{"))]); + + assert!(matches!( + result, + Err(Error::Usage(message)) + if message.starts_with("failed to parse release issue tracker output as JSON:") + )); + assert_eq!(calls, expected_release_tracker_calls()); + assert_eq!(remaining_responses, 0); + } + + #[test] + fn release_guard_fails_closed_on_milestone_api_error() { + let (result, calls, remaining_responses) = + exercise_release_tracker_guard(vec![Ok(gh_failure("HTTP 500"))]); + + assert!(matches!( + result, + Err(Error::CheckFailed { failures, .. }) + if failures.len() == 1 && failures[0].contains("HTTP 500") + )); + assert_eq!(calls, &expected_release_tracker_calls()[..1]); + assert_eq!(remaining_responses, 0); + } + + #[test] + fn release_guard_fails_closed_on_issue_list_api_error() { + let (result, calls, remaining_responses) = exercise_release_tracker_guard(vec![ + Ok(gh_success("v1.2.3\n")), + Ok(gh_failure("GraphQL transport unavailable")), + ]); + + assert!(matches!( + result, + Err(Error::CheckFailed { failures, .. }) + if failures.len() == 1 + && failures[0].contains("GraphQL transport unavailable") + )); + assert_eq!(calls, expected_release_tracker_calls()); + assert_eq!(remaining_responses, 0); + } + + #[test] + fn release_guard_reports_gh_spawn_error_without_fallback_query() { + let (result, calls, remaining_responses) = exercise_release_tracker_guard(vec![Err( + std::io::Error::new(std::io::ErrorKind::NotFound, "gh not found"), + )]); + + assert!(matches!( + result, + Err(Error::Usage(message)) + if message.contains("failed to spawn `gh api --hostname github.com --paginate") + && message.contains("gh not found") + )); + assert_eq!(calls, &expected_release_tracker_calls()[..1]); + assert_eq!(remaining_responses, 0); + } + + #[test] + fn release_guard_uses_full_prerelease_tag_as_milestone() { + let query = release_milestone_issue_query("v0.3.0-alpha.2", "flyingrobots/wesley"); + + assert_eq!( + query + .windows(2) + .find(|args| args[0] == "--milestone") + .map(|args| args[1].as_str()), + Some("v0.3.0-alpha.2") ); } #[test] - fn current_release_issue_text_ignores_comment_only_matches() { - let content = serde_json::json!([ - { - "number": 1, - "title": "release: ship v1.2.3", - "url": "https://github.com/flyingrobots/wesley/issues/1", - "body": "release umbrella" - }, - { - "number": 2, - "title": "future runtime work", - "url": "https://github.com/flyingrobots/wesley/issues/2", - "body": "No current release assignment here." - }, - { - "number": 3, - "title": "future evidence work", - "url": "https://github.com/flyingrobots/wesley/issues/3", - "body": "Related PR comment mentions are not part of this JSON payload." - }, - { - "number": 4, - "title": "version substring", - "url": "https://github.com/flyingrobots/wesley/issues/4", - "body": "v1.2.30 belongs to another release lane." - }, - { - "number": 5, - "title": "bare version blocker", - "url": "https://github.com/flyingrobots/wesley/issues/5", - "body": "Must clear before 1.2.3." - }, - { - "number": 6, - "title": "unrelated method migration", - "url": "https://github.com/flyingrobots/wesley/issues/6", - "body": "METHOD v2.1.0 migration text is not this release lane." - } - ]) - .to_string(); + fn release_guard_queries_open_milestones_for_target() { + assert_eq!( + release_milestone_query("flyingrobots/wesley"), + vec![ + "api", + "--hostname", + "github.com", + "--paginate", + "repos/flyingrobots/wesley/milestones?state=open&per_page=100", + "--jq", + ".[].title", + ] + ); + } + + #[test] + fn release_milestone_presence_requires_an_exact_title() { + let titles = "v1.2.2\nv1.2.3\nv1.2.30\n"; + + assert!(release_milestone_exists(titles, "v1.2.3")); + assert!(!release_milestone_exists(titles, "v1.2.4")); + assert!(!release_milestone_exists("v1.2.30\n", "v1.2.3")); + } + + #[test] + fn release_repository_discovery_queries_fetch_then_all_push_urls() { + let (result, calls, remaining_responses) = exercise_release_repository_discovery( + Some("flyingrobots/wesley"), + vec![ + "https://github.com/flyingrobots/wesley.git", + "git@github.com:flyingrobots/wesley.git\nssh://git@github.com/flyingrobots/wesley.git", + ], + ); + + assert!(matches!(result, Ok(repository) if repository == "flyingrobots/wesley")); + assert_eq!( + calls, + vec![ + string_args(&["remote", "get-url", "origin"]), + string_args(&["remote", "get-url", "--push", "--all", "origin"]), + ] + ); + assert_eq!(remaining_responses, 0); + } + + #[test] + fn release_repository_discovery_checks_actual_push_output() { + let (result, calls, remaining_responses) = exercise_release_repository_discovery( + Some("flyingrobots/wesley"), + vec![ + "https://github.com/flyingrobots/wesley.git", + "git@github.com:flyingrobots/fork.git", + ], + ); + + assert!(matches!( + result, + Err(Error::Usage(message)) + if message == "origin push repository `flyingrobots/fork` does not match origin fetch repository `flyingrobots/wesley`" + )); + assert_eq!( + calls, + vec![ + string_args(&["remote", "get-url", "origin"]), + string_args(&["remote", "get-url", "--push", "--all", "origin"]), + ] + ); + assert_eq!(remaining_responses, 0); + } + + #[test] + fn release_repository_uses_origin_when_ambient_value_is_absent() { + assert_eq!( + resolve_release_github_repository( + "git@github.com:flyingrobots/wesley.git", + "git@github.com:flyingrobots/wesley.git", + None, + ), + Ok("flyingrobots/wesley".to_string()) + ); + } + + #[test] + fn release_repository_accepts_ambient_value_matching_origin() { + assert_eq!( + resolve_release_github_repository( + "https://github.com/flyingrobots/wesley.git", + "git@github.com:flyingrobots/wesley.git", + Some("flyingrobots/wesley"), + ), + Ok("flyingrobots/wesley".to_string()) + ); + } - let matches = parse_current_version_issue_text(&content, "v1.2.3", "1.2.3").unwrap(); + #[test] + fn release_repository_rejects_ambient_value_mismatching_origin() { + let error = resolve_release_github_repository( + "https://github.com/flyingrobots/wesley.git", + "git@github.com:flyingrobots/wesley.git", + Some("flyingrobots/another-repo"), + ) + .unwrap_err(); + + assert_eq!( + error, + "GITHUB_REPOSITORY `flyingrobots/another-repo` does not match origin fetch and push repository `flyingrobots/wesley`" + ); + } + + #[test] + fn release_repository_rejects_malformed_ambient_value() { + let error = resolve_release_github_repository( + "https://github.com/flyingrobots/wesley.git", + "git@github.com:flyingrobots/wesley.git", + Some("not-a-repository"), + ) + .unwrap_err(); + + assert_eq!( + error, + "GITHUB_REPOSITORY `not-a-repository` is not a valid owner/repository path" + ); + } - assert_eq!(matches.len(), 2); + #[test] + fn release_repository_accepts_multiple_matching_push_urls() { assert_eq!( - matches[0].display, - "#1 release: ship v1.2.3 https://github.com/flyingrobots/wesley/issues/1 (title/body text)" + resolve_release_github_repository( + "https://github.com/flyingrobots/wesley.git", + "git@github.com:flyingrobots/wesley.git\nssh://git@github.com/flyingrobots/wesley.git\n", + Some("flyingrobots/wesley"), + ), + Ok("flyingrobots/wesley".to_string()) ); + } + + #[test] + fn release_repository_rejects_mismatched_push_url() { + let error = resolve_release_github_repository( + "https://github.com/flyingrobots/wesley.git", + "git@github.com:flyingrobots/fork.git", + Some("flyingrobots/wesley"), + ) + .unwrap_err(); + assert_eq!( - matches[1].display, - "#5 bare version blocker https://github.com/flyingrobots/wesley/issues/5 (title/body text)" + error, + "origin push repository `flyingrobots/fork` does not match origin fetch repository `flyingrobots/wesley`" + ); + } + + #[test] + fn release_repository_rejects_multiple_distinct_push_destinations() { + let error = resolve_release_github_repository( + "https://github.com/flyingrobots/wesley.git", + "git@github.com:flyingrobots/wesley.git\ngit@github.com:flyingrobots/mirror.git\n", + None, + ) + .unwrap_err(); + + assert_eq!( + error, + "origin push repository `flyingrobots/mirror` does not match origin fetch repository `flyingrobots/wesley`" + ); + } + + #[test] + fn release_repository_rejects_unparseable_push_url() { + let error = resolve_release_github_repository( + "https://github.com/flyingrobots/wesley.git", + "ssh://example.test/flyingrobots/wesley.git", + None, + ) + .unwrap_err(); + + assert_eq!( + error, + "could not infer GitHub repository from origin push URL `ssh://example.test/flyingrobots/wesley.git`" ); } @@ -3922,54 +4178,6 @@ mod tests { ); } - #[test] - fn prior_version_issue_lanes_find_older_semver_labels_and_milestones() { - let content = serde_json::json!([ - { - "number": 1, - "title": "Older label", - "url": "https://github.com/flyingrobots/wesley/issues/1", - "labels": [ - { "name": "triage:bad-code" }, - { "name": "v0.0.4" }, - { "name": "v0.0.5" }, - { "name": "v0.0.4+build" } - ], - "milestone": null - }, - { - "number": 2, - "title": "Older milestone", - "url": "https://github.com/flyingrobots/wesley/issues/2", - "labels": [], - "milestone": { "title": "0.0.3" } - }, - { - "number": 3, - "title": "Current release", - "url": "https://github.com/flyingrobots/wesley/issues/3", - "labels": [{ "name": "v0.0.5" }], - "milestone": { "title": "0.0.5" } - } - ]) - .to_string(); - - let matches = parse_prior_version_issue_lanes(&content, "0.0.5").unwrap(); - - assert_eq!(matches.len(), 2); - assert_eq!( - matches[0].display, - "#1 Older label https://github.com/flyingrobots/wesley/issues/1 (prior version label `v0.0.4`)" - ); - assert_eq!( - matches[1].display, - "#2 Older milestone https://github.com/flyingrobots/wesley/issues/2 (prior version milestone `0.0.3`)" - ); - assert!(version_from_lane_name("v0.0.4").is_some()); - assert!(version_from_lane_name("triage:bad-code").is_none()); - assert!(version_from_lane_name("v0.0.4+build").is_none()); - } - #[test] fn readme_version_headline_requires_exact_heading_line() { assert!(readme_has_exact_version_headline( From 234bc452ca0e5954aef42f2e0fa82cd2d99ea1ca Mon Sep 17 00:00:00 2001 From: James Ross Date: Fri, 24 Jul 2026 19:43:45 -0700 Subject: [PATCH 02/16] ci: provision ripgrep for repo Bats checks --- .github/actions/install-bats/action.yml | 8 ++++---- test/ci-workflows.bats | 5 +++++ 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/.github/actions/install-bats/action.yml b/.github/actions/install-bats/action.yml index cb81459c..5c95b41c 100644 --- a/.github/actions/install-bats/action.yml +++ b/.github/actions/install-bats/action.yml @@ -1,11 +1,11 @@ -name: 'Install Bats and JQ' -description: 'Installs bats and jq on Ubuntu runners' +name: 'Install Bats, JQ, and ripgrep' +description: 'Installs bats, jq, and ripgrep on Ubuntu runners' runs: using: 'composite' steps: - name: apt-get update shell: bash run: sudo apt-get update - - name: install bats and jq + - name: install bats, jq, and ripgrep shell: bash - run: sudo apt-get install -y bats jq + run: sudo apt-get install -y bats jq ripgrep diff --git a/test/ci-workflows.bats b/test/ci-workflows.bats index e6739baa..4ee035ae 100644 --- a/test/ci-workflows.bats +++ b/test/ci-workflows.bats @@ -282,6 +282,11 @@ load 'vendor/bats-plugins/bats-assert/load' [ "$output" -ge 3 ] } +@test "repo Bats installer provisions command dependencies" { + run grep -F "sudo apt-get install -y bats jq ripgrep" .github/actions/install-bats/action.yml + assert_success +} + @test "cert-shipme certifies only landed target-branch commits" { run bash -lc "grep -F 'pull_request:' .github/workflows/cert-shipme.yml | wc -l" assert_success From ec656a88083dd5e585d2d3dd4fd5d20b205ba1fc Mon Sep 17 00:00:00 2001 From: James Ross Date: Fri, 24 Jul 2026 21:22:48 -0700 Subject: [PATCH 03/16] Fix: isolate SHIPME fixture commit SHA --- test/ci-workflows.bats | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test/ci-workflows.bats b/test/ci-workflows.bats index 4ee035ae..9b49dad6 100644 --- a/test/ci-workflows.bats +++ b/test/ci-workflows.bats @@ -403,7 +403,8 @@ load 'vendor/bats-plugins/bats-assert/load' @test "shipme certificate fixture prepares PASS realm and exact evidence" { tmp_dir="$(mktemp -d -t wesley-shipme-fixture-XXXXXX)" - run bash -lc "cd '$tmp_dir' && node '$PWD/scripts/prepare-shipme-cert-fixture.mjs' && grep -F '\"verdict\": \"PASS\"' .wesley-cache/realm.json && grep -F '\"version\": \"2.0.0\"' .wesley-cache/scores.json && grep -F '\"commit\": \"abcdef1234567890abcdef1234567890abcdef12\"' .wesley-cache/scores.json && grep -F '\"metadata\"' .wesley-cache/scores.json && grep -F '\"readiness\"' .wesley-cache/bundle.json && grep -F '\"lines\": \"1-2\"' .wesley-cache/bundle.json && grep -F '\"lines\": \"1-1\"' .wesley-cache/bundle.json" + fixture_sha="abcdef1234567890abcdef1234567890abcdef12" + run bash -lc "cd '$tmp_dir' && GITHUB_SHA='$fixture_sha' node '$PWD/scripts/prepare-shipme-cert-fixture.mjs' && grep -F '\"verdict\": \"PASS\"' .wesley-cache/realm.json && grep -F '\"version\": \"2.0.0\"' .wesley-cache/scores.json && grep -F '\"commit\": \"$fixture_sha\"' .wesley-cache/scores.json && grep -F '\"metadata\"' .wesley-cache/scores.json && grep -F '\"readiness\"' .wesley-cache/bundle.json && grep -F '\"lines\": \"1-2\"' .wesley-cache/bundle.json && grep -F '\"lines\": \"1-1\"' .wesley-cache/bundle.json" rm -rf "$tmp_dir" assert_success } From 7f3017e70d81bc2cf981e221f6ce2c4e3d0df950 Mon Sep 17 00:00:00 2001 From: James Ross Date: Fri, 24 Jul 2026 21:24:09 -0700 Subject: [PATCH 04/16] Fix: reject duplicate release milestones --- xtask/src/main.rs | 50 +++++++++++++++++++++++++++++++++++++---------- 1 file changed, 40 insertions(+), 10 deletions(-) diff --git a/xtask/src/main.rs b/xtask/src/main.rs index bf51767f..e4c40b6e 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -1876,15 +1876,20 @@ where { let query = release_milestone_query(repo); let stdout = run_release_tracker_query(&query, run_gh)?; - if release_milestone_exists(&stdout, tag) { - Ok(()) - } else { - Err(Error::CheckFailed { + match release_milestone_match_count(&stdout, tag) { + 1 => Ok(()), + 0 => Err(Error::CheckFailed { check: "release issue tracker".to_string(), failures: vec![format!( "required open version milestone `{tag}` does not exist in `{repo}`" )], - }) + }), + count => Err(Error::CheckFailed { + check: "release issue tracker".to_string(), + failures: vec![format!( + "required exactly one open version milestone `{tag}` in `{repo}`, found {count}" + )], + }), } } @@ -1921,8 +1926,8 @@ fn release_milestone_query(repo: &str) -> Vec { ] } -fn release_milestone_exists(content: &str, target: &str) -> bool { - content.lines().any(|title| title == target) +fn release_milestone_match_count(content: &str, target: &str) -> usize { + content.lines().filter(|title| *title == target).count() } fn release_milestone_issue_query(tag: &str, repo: &str) -> Vec { @@ -3769,6 +3774,24 @@ mod tests { assert_eq!(remaining_responses, 0); } + #[test] + fn release_guard_rejects_duplicate_target_milestones_before_issue_query() { + let (result, calls, remaining_responses) = exercise_release_tracker_guard(vec![Ok( + gh_success("v1.2.3\nv1.2.3\n"), + )]); + + assert!(matches!( + result, + Err(Error::CheckFailed { check, failures }) + if check == "release issue tracker" + && failures == vec![ + "required exactly one open version milestone `v1.2.3` in `flyingrobots/wesley`, found 2" + ] + )); + assert_eq!(calls, &expected_release_tracker_calls()[..1]); + assert_eq!(remaining_responses, 0); + } + #[test] fn release_guard_reports_open_target_milestone_issue() { let (result, calls, remaining_responses) = exercise_release_tracker_guard(vec![ @@ -3910,9 +3933,16 @@ mod tests { fn release_milestone_presence_requires_an_exact_title() { let titles = "v1.2.2\nv1.2.3\nv1.2.30\n"; - assert!(release_milestone_exists(titles, "v1.2.3")); - assert!(!release_milestone_exists(titles, "v1.2.4")); - assert!(!release_milestone_exists("v1.2.30\n", "v1.2.3")); + assert_eq!(release_milestone_match_count(titles, "v1.2.3"), 1); + assert_eq!(release_milestone_match_count(titles, "v1.2.4"), 0); + assert_eq!( + release_milestone_match_count("v1.2.30\n", "v1.2.3"), + 0 + ); + assert_eq!( + release_milestone_match_count("v1.2.3\nv1.2.3\n", "v1.2.3"), + 2 + ); } #[test] From 0b33c624b9ccfde36c6ee7183f6037a3d15dc815 Mon Sep 17 00:00:00 2001 From: James Ross Date: Fri, 24 Jul 2026 21:24:40 -0700 Subject: [PATCH 05/16] Fix: format duplicate milestone regression --- xtask/src/main.rs | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/xtask/src/main.rs b/xtask/src/main.rs index e4c40b6e..fd9d91be 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -3776,9 +3776,8 @@ mod tests { #[test] fn release_guard_rejects_duplicate_target_milestones_before_issue_query() { - let (result, calls, remaining_responses) = exercise_release_tracker_guard(vec![Ok( - gh_success("v1.2.3\nv1.2.3\n"), - )]); + let (result, calls, remaining_responses) = + exercise_release_tracker_guard(vec![Ok(gh_success("v1.2.3\nv1.2.3\n"))]); assert!(matches!( result, @@ -3935,10 +3934,7 @@ mod tests { assert_eq!(release_milestone_match_count(titles, "v1.2.3"), 1); assert_eq!(release_milestone_match_count(titles, "v1.2.4"), 0); - assert_eq!( - release_milestone_match_count("v1.2.30\n", "v1.2.3"), - 0 - ); + assert_eq!(release_milestone_match_count("v1.2.30\n", "v1.2.3"), 0); assert_eq!( release_milestone_match_count("v1.2.3\nv1.2.3\n", "v1.2.3"), 2 From f2f9964be2667add1adee3e57348c1dd7c0a1a77 Mon Sep 17 00:00:00 2001 From: James Ross Date: Fri, 24 Jul 2026 21:27:27 -0700 Subject: [PATCH 06/16] Fix: make scheduling predicates exhaustive --- .continuum/release.yml | 4 ++-- .github/pull_request_template.md | 2 +- AGENTS.md | 13 ++++++++----- docs/topics/contributing/first-pr.md | 8 +++++--- test/release-governance.bats | 23 ++++++++++++++++++++--- xtask/src/main.rs | 8 ++++++-- 6 files changed, 42 insertions(+), 16 deletions(-) diff --git a/.continuum/release.yml b/.continuum/release.yml index e003e14d..51c7d809 100644 --- a/.continuum/release.yml +++ b/.continuum/release.yml @@ -118,8 +118,8 @@ publish: issue_model: live_tracker: github scheduling_authority: 'plain v{version} milestone' - scheduled_state: 'exactly one v{version} milestone and no triage:* or concrete version label' - unscheduled_state: 'exactly one triage:* label and no milestone' + scheduled_state: 'exactly one milestone named v{version}; no extra milestone or triage:*, retired lane:*, or concrete version label' + unscheduled_state: 'exactly one triage:* label; no milestone, retired lane:*, or concrete version label' classification_axis: 'type, status, legend:*, work:*, and pkg:* labels' release_gate: 'final pre-tag issue in the target version milestone' diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index f720143b..adc466d0 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -28,7 +28,7 @@ Closes # ## Tracker Hygiene - [ ] Linked issue had `work-in-progress` while active. -- [ ] Linked issue is scheduled in one plain `vX.Y.Z` milestone and has no `triage:*` or version label. +- [ ] Linked issue has exactly one milestone, named plain `vX.Y.Z`, and no `triage:*`, retired `lane:*`, or concrete-version scheduling label. - [ ] Follow-up work is captured as GitHub Issues, not hidden in chat or local-only backlog files. ## Risk diff --git a/AGENTS.md b/AGENTS.md index b87c46a0..4c44db0f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -35,9 +35,10 @@ Do not audit the repository by recursively walking the filesystem. Follow the au - **`docs/BEARING.md`**: Current execution gravity and active tensions. - **`docs/design/README.md`**: Active design packets and structural doctrine. - **GitHub Issues**: The active source of truth for pending work. Unscheduled - intake has exactly one `triage:*` label and no milestone. Work scheduled - into a named release has exactly one plain `vX.Y.Z` milestone and no - `triage:*` or concrete-version scheduling label. See + intake has exactly one `triage:*` label and no milestone, retired `lane:*`, + or concrete-version scheduling label. Work scheduled into a named release + has exactly one milestone, named plain `vX.Y.Z`, and no `triage:*`, retired + `lane:*`, or concrete-version scheduling label. See **`docs/topics/contributing/triage.md`**. ### 4. The Proof @@ -61,8 +62,10 @@ After altering files: 1. **Verify Truth**: Ensure documentation is updated if behavior or structure changed. 2. **Log Debt**: Add follow-on work as either unscheduled intake (exactly one - `triage:*` label and no milestone) or scheduled work (exactly one plain - `vX.Y.Z` milestone and no triage or concrete-version scheduling label). + `triage:*` label and no milestone, retired `lane:*`, or concrete-version + scheduling label) or scheduled work (exactly one milestone, named plain + `vX.Y.Z`, and no triage, retired `lane:*`, or concrete-version scheduling + label). 3. **Commit**: Use focused, conventional commit messages. Propose a draft before executing. 4. **Validate**: Run `pnpm run preflight`. diff --git a/docs/topics/contributing/first-pr.md b/docs/topics/contributing/first-pr.md index a4fc1c2c..881bf1b1 100644 --- a/docs/topics/contributing/first-pr.md +++ b/docs/topics/contributing/first-pr.md @@ -43,7 +43,8 @@ Use the [good first issue query](https://github.com/flyingrobots/wesley/issues?q=is%3Aissue%20state%3Aopen%20label%3A%22good%20first%20issue%22) and choose an issue that has: -- exactly one plain `vX.Y.Z` milestone and no `triage:*` or version label +- exactly one milestone, named plain `vX.Y.Z`, and no `triage:*`, retired + `lane:*`, or concrete-version scheduling label - no `work-in-progress` label - one primary file or a very small file set - one local validation command in the acceptance criteria @@ -89,8 +90,9 @@ context: - scope names one primary file or tiny file set - acceptance criteria include exactly one required local command - tracker metadata has exactly one scheduling state: either one `triage:*` - label and no milestone, or one plain `vX.Y.Z` milestone and no `triage:*` or - version label + label and no milestone, retired `lane:*`, or concrete-version scheduling + label, or exactly one milestone, named plain `vX.Y.Z`, and no `triage:*`, + retired `lane:*`, or concrete-version scheduling label - starter issues are scheduled with a plain `vX.Y.Z` milestone; narrative release outcomes do not become additional milestones - no advanced Wesley term appears without a plain-English alias diff --git a/test/release-governance.bats b/test/release-governance.bats index a76ff22f..d11a8f1f 100644 --- a/test/release-governance.bats +++ b/test/release-governance.bats @@ -146,15 +146,32 @@ load 'vendor/bats-plugins/bats-assert/load' run rg -n "Goalpost:|Release:" .continuum/release.yml assert_failure - run grep -F "scheduled_state: 'exactly one v{version} milestone and no triage:* or concrete version label'" .continuum/release.yml + run grep -F "scheduled_state: 'exactly one milestone named v{version}; no extra milestone or triage:*, retired lane:*, or concrete version label'" .continuum/release.yml assert_success - run grep -F "unscheduled_state: 'exactly one triage:* label and no milestone'" .continuum/release.yml + run grep -F "unscheduled_state: 'exactly one triage:* label; no milestone, retired lane:*, or concrete version label'" .continuum/release.yml assert_success } +@test "scheduling predicates reject every retired scheduling state" { + run grep -F "scheduled_state: 'exactly one milestone named v{version}; no extra milestone or triage:*, retired lane:*, or concrete version label'" .continuum/release.yml + assert_success + + run grep -F "unscheduled_state: 'exactly one triage:* label; no milestone, retired lane:*, or concrete version label'" .continuum/release.yml + assert_success + + run grep -F 'Linked issue has exactly one milestone, named plain `vX.Y.Z`, and no `triage:*`, retired `lane:*`, or concrete-version scheduling label.' .github/pull_request_template.md + assert_success + + for path in AGENTS.md docs/topics/contributing/first-pr.md; do + run bash -lc "grep -F 'retired \`lane:*\`' '$path' | wc -l" + assert_success + [ "$output" -ge 2 ] + done +} + @test "issue and pull request templates preserve the scheduling invariant" { - run grep -F 'Linked issue is scheduled in one plain `vX.Y.Z` milestone and has no `triage:*` or version label.' .github/pull_request_template.md + run grep -F 'Linked issue has exactly one milestone, named plain `vX.Y.Z`, and no `triage:*`, retired `lane:*`, or concrete-version scheduling label.' .github/pull_request_template.md assert_success run grep -F 'Linked issue is either unscheduled' .github/pull_request_template.md diff --git a/xtask/src/main.rs b/xtask/src/main.rs index fd9d91be..553f70d1 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -3629,11 +3629,15 @@ mod tests { let issue_model = yaml_field(&profile, "issue_model").expect("issue_model should exist"); assert_eq!( yaml_field(issue_model, "scheduled_state").and_then(Yaml::as_str), - Some("exactly one v{version} milestone and no triage:* or concrete version label") + Some( + "exactly one milestone named v{version}; no extra milestone or triage:*, retired lane:*, or concrete version label" + ) ); assert_eq!( yaml_field(issue_model, "unscheduled_state").and_then(Yaml::as_str), - Some("exactly one triage:* label and no milestone") + Some( + "exactly one triage:* label; no milestone, retired lane:*, or concrete version label" + ) ); let issue_forms = [ From 518460d5919a28f1653592a52398f510dbe72c49 Mon Sep 17 00:00:00 2001 From: James Ross Date: Fri, 24 Jul 2026 21:32:08 -0700 Subject: [PATCH 07/16] Fix: scope scheduling rules to open work --- CONTRIBUTING.md | 3 +++ docs/BEARING.md | 3 +++ docs/METHOD.md | 3 +++ docs/governance/RELEASE_CHECKLIST.md | 3 +++ docs/governance/RELEASE_POLICY.md | 3 +++ docs/method/release-runbook.md | 2 ++ docs/method/release.md | 2 ++ docs/topics/releases.md | 3 +++ test/release-governance.bats | 18 ++++++++++++++++++ 9 files changed, 40 insertions(+) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0e729c83..d5a2a4ab 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -64,6 +64,9 @@ An unscheduled issue has exactly one `triage:*` label and no milestone. A scheduled issue has exactly one plain `vX.Y.Z` milestone and no `triage:*` or concrete-version scheduling label. +These scheduling invariants govern current open work only. Closed issues, closed +milestones, and historical labels remain preserved evidence. + Repository files are the evidence ledger. Design packets, witnesses, retros, release notes, and signpost docs record stable truth and proof after work is done. The Chronicle files in the repo root are historical archive only. diff --git a/docs/BEARING.md b/docs/BEARING.md index feff354d..e1cfbe08 100644 --- a/docs/BEARING.md +++ b/docs/BEARING.md @@ -28,6 +28,9 @@ work. All implementation, documentation, preparation, and gate issues for a release share that milestone. Before tagging, move or close every other open issue in the milestone and close the gate last. +These scheduling invariants govern current open work only. Closed issues, closed +milestones, and historical labels remain preserved evidence. + The current formal release schedule is the GitHub milestone list: . diff --git a/docs/METHOD.md b/docs/METHOD.md index 4f3a1ac2..bb0dac25 100644 --- a/docs/METHOD.md +++ b/docs/METHOD.md @@ -51,6 +51,9 @@ classify work but never schedule it into a release. All work committed to a release shares its version milestone; before tagging, move or close every other open issue and close the release gate last. +These scheduling invariants govern current open work only. Closed issues, closed +milestones, and historical labels remain preserved evidence. + ## GitHub Issue Triage Use [Issue Triage](./topics/contributing/triage.md) for the label contract. diff --git a/docs/governance/RELEASE_CHECKLIST.md b/docs/governance/RELEASE_CHECKLIST.md index 0678bf5f..16ac2283 100644 --- a/docs/governance/RELEASE_CHECKLIST.md +++ b/docs/governance/RELEASE_CHECKLIST.md @@ -22,6 +22,9 @@ issue, including the gate, belongs to it. Complete this review after every other milestone issue is closed or moved; then close the gate before creating the signed local tag. +These scheduling invariants govern current open work only. Closed issues, closed +milestones, and historical labels remain preserved evidence. + See [`RELEASE_POLICY.md`](RELEASE_POLICY.md) for the full enforcement matrix and rationale. diff --git a/docs/governance/RELEASE_POLICY.md b/docs/governance/RELEASE_POLICY.md index 76d7e3b0..c8da3941 100644 --- a/docs/governance/RELEASE_POLICY.md +++ b/docs/governance/RELEASE_POLICY.md @@ -35,6 +35,9 @@ milestone. Project fields, grouping labels, titles, and body text do not schedule work. The gate is the final open issue and must close before the signed local tag is created. +These scheduling invariants govern current open work only. Closed issues, closed +milestones, and historical labels remain preserved evidence. + Here `vX.Y.Z` means the exact tag-form SemVer. Every prerelease uses its full milestone title, such as `v0.3.0-alpha.2`. diff --git a/docs/method/release-runbook.md b/docs/method/release-runbook.md index 8d8ee31d..f212cf7c 100644 --- a/docs/method/release-runbook.md +++ b/docs/method/release-runbook.md @@ -23,6 +23,8 @@ names, and verification commands. cannot resolve it. - Confirm every scheduled issue, including the release gate, uses that milestone and no version label or narrative milestone as a second schedule. +- These scheduling invariants govern current open work only. Closed issues, + closed milestones, and historical labels remain preserved evidence. - Verify required tools, credentials, signing configuration, CI visibility, and registry visibility are available; abort if missing. - Ensure every required validation and publish verification step succeeds; diff --git a/docs/method/release.md b/docs/method/release.md index d162252d..1c06ac80 100644 --- a/docs/method/release.md +++ b/docs/method/release.md @@ -45,6 +45,8 @@ A valid Wesley release has all of the following: labels, issue titles, and body text may explain work, but they do not schedule it. Release guards query only the exact version milestone and fail if it is missing. + These scheduling invariants govern current open work only. Closed issues, + closed milestones, and historical labels remain preserved evidence. Here `vX.Y.Z` means exact tag-form SemVer. Every prerelease uses its full milestone title, such as `v0.3.0-alpha.2`. 3. **Honest scope**: must-ship, may-slip, and explicitly-not-included work is diff --git a/docs/topics/releases.md b/docs/topics/releases.md index eabbf572..45bf334a 100644 --- a/docs/topics/releases.md +++ b/docs/topics/releases.md @@ -28,6 +28,9 @@ work, but they do not schedule it. The release gate is the final open issue in the milestone: close it after every other issue is closed, moved, or cut, and before creating the signed local tag. +These scheduling invariants govern current open work only. Closed issues, closed +milestones, and historical labels remain preserved evidence. + Here `vX.Y.Z` means exact tag-form SemVer. Every prerelease uses its full milestone title, such as `v0.3.0-alpha.2`. diff --git a/test/release-governance.bats b/test/release-governance.bats index d11a8f1f..e187cf7b 100644 --- a/test/release-governance.bats +++ b/test/release-governance.bats @@ -178,6 +178,24 @@ load 'vendor/bats-plugins/bats-assert/load' assert_failure } +@test "scheduling doctrine scopes invariants to current open work" { + local doctrine_paths=( + docs/METHOD.md + CONTRIBUTING.md + docs/BEARING.md + docs/governance/RELEASE_POLICY.md + docs/method/release-runbook.md + docs/governance/RELEASE_CHECKLIST.md + docs/method/release.md + docs/topics/releases.md + ) + + for path in "${doctrine_paths[@]}"; do + run rg -U "These scheduling invariants govern current open work only\\. Closed issues,[[:space:]]+closed[[:space:]]+milestones, and historical labels remain preserved evidence\\." "$path" + assert_success + done +} + @test "triage doctrine defines mutually exclusive scheduled states" { run grep -F 'Unscheduled: exactly one `triage:*` label and no milestone.' docs/topics/contributing/triage.md assert_success From e8b5c373ffd5eeb3a1174c6d192b093b901f11a7 Mon Sep 17 00:00:00 2001 From: James Ross Date: Fri, 24 Jul 2026 21:33:53 -0700 Subject: [PATCH 08/16] Fix: move tracker migration after governance merge --- docs/topics/contributing/triage.md | 17 +++++++++-------- test/release-governance.bats | 7 +++++-- 2 files changed, 14 insertions(+), 10 deletions(-) diff --git a/docs/topics/contributing/triage.md b/docs/topics/contributing/triage.md index 84f7f05d..ee3b3ab0 100644 --- a/docs/topics/contributing/triage.md +++ b/docs/topics/contributing/triage.md @@ -88,11 +88,11 @@ intake; scheduled release scope comes from the issue's plain version milestone. ## One-Time Live Cutover The governance change that establishes this model and the live GitHub metadata -must cross one controlled boundary. Do not merge the enforcement change while -open issues still use the retired schedule. +must cross one controlled boundary. Merge the approved governance pull request +before any live tracker mutation. The merged doctrine, templates, release +profile, and guards are the authority for the cutover. -After the governance pull request is approved, but immediately before it is -merged: +Immediately after that merge, and before any other planning or release write: 1. Freeze issue scheduling, milestone edits, release-gate closure, and tag creation for the duration of the cutover. @@ -125,10 +125,11 @@ merged: 8. Repeat the two snapshot queries and verify every open issue satisfies exactly one scheduling state, every gate shares its exact version milestone, and no retired scheduling label remains assigned to an open issue. -9. Merge enforcement only while that verification is clean, then lift the - freeze. If any mutation or verification fails, stop, leave the pull request - unmerged, and keep the release freeze in place until the cutover can be - completed and reverified. +9. Lift the freeze only while that verification is clean. If any mutation or + verification fails, stop, leave the merged enforcement intact, record the + failure in the migration issue, and keep the release freeze in place until + the live metadata is repaired and reverified. Do not restore a partial + version of the retired scheduling model. Retired label definitions may remain for historical search, but after cutover they are never assigned to open issues. diff --git a/test/release-governance.bats b/test/release-governance.bats index e187cf7b..531658b1 100644 --- a/test/release-governance.bats +++ b/test/release-governance.bats @@ -205,9 +205,12 @@ load 'vendor/bats-plugins/bats-assert/load' } @test "triage doctrine requires an atomic live tracker cutover" { - run grep -F "After the governance pull request is approved, but immediately before it is" docs/topics/contributing/triage.md + run rg -U "Merge the approved governance pull request\\s+before any live tracker mutation\\." docs/topics/contributing/triage.md assert_success + run grep -F "immediately before it is" docs/topics/contributing/triage.md + assert_failure + run grep -F "Freeze issue scheduling, milestone edits, release-gate closure, and tag" docs/topics/contributing/triage.md assert_success @@ -241,7 +244,7 @@ load 'vendor/bats-plugins/bats-assert/load' run rg -U 'Never\s+move the closed issues that remain historical evidence' docs/topics/contributing/triage.md assert_success - run grep -F "Merge enforcement only while that verification is clean" docs/topics/contributing/triage.md + run grep -F "leave the merged enforcement intact" docs/topics/contributing/triage.md assert_success } From 929366682fe6212f1956e355ec74f55c49158e71 Mon Sep 17 00:00:00 2001 From: James Ross Date: Fri, 24 Jul 2026 21:38:18 -0700 Subject: [PATCH 09/16] Fix: keep priority and status in project fields --- .continuum/release.yml | 3 ++- CHANGELOG.md | 3 ++- docs/topics/contributing/triage.md | 9 +++++---- test/release-governance.bats | 14 ++++++++++++++ xtask/src/main.rs | 8 ++++++++ 5 files changed, 31 insertions(+), 6 deletions(-) diff --git a/.continuum/release.yml b/.continuum/release.yml index 51c7d809..8efa6260 100644 --- a/.continuum/release.yml +++ b/.continuum/release.yml @@ -120,7 +120,8 @@ issue_model: scheduling_authority: 'plain v{version} milestone' scheduled_state: 'exactly one milestone named v{version}; no extra milestone or triage:*, retired lane:*, or concrete version label' unscheduled_state: 'exactly one triage:* label; no milestone, retired lane:*, or concrete version label' - classification_axis: 'type, status, legend:*, work:*, and pkg:* labels' + classification_axis: 'type, legend:*, group:*, work:*, and pkg:* labels' + project_fields: 'priority and workflow status' release_gate: 'final pre-tag issue in the target version milestone' release_evidence: diff --git a/CHANGELOG.md b/CHANGELOG.md index 04d2c382..966adb35 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,8 @@ The format is based on Keep a Changelog, and this project adheres to Semantic Ve - Replaced slogan goalpost milestones and duplicate version scheduling labels with one plain `vX.Y.Z` milestone per planned release. Release guards now require that exact open milestone and block on its open issues; the release - gate closes before the immutable tag is created. + gate closes before the immutable tag is created. Priority and workflow status + remain Project fields rather than classification labels. ## [0.3.0-alpha.1] - 2026-07-15 diff --git a/docs/topics/contributing/triage.md b/docs/topics/contributing/triage.md index ee3b3ab0..bc4fda9a 100644 --- a/docs/topics/contributing/triage.md +++ b/docs/topics/contributing/triage.md @@ -80,10 +80,11 @@ inside the plain version milestone. Topic labels are useful, but they are not scheduling labels. -Use `legend:*`, `group:*`, `pkg:*`, `work:*`, `priority:*`, and ordinary -work-type labels (`bug`, `feature`, `chore`, `docs`, `tests`, `ci`) to explain -what kind of work an issue represents. Only `triage:*` marks unscheduled -intake; scheduled release scope comes from the issue's plain version milestone. +Use `legend:*`, `group:*`, `pkg:*`, `work:*`, and ordinary work-type labels +(`bug`, `feature`, `chore`, `docs`, `tests`, `ci`) to explain what kind of work +an issue represents. Use Project fields for priority and workflow status. Only +`triage:*` marks unscheduled intake; scheduled release scope comes from the +issue's plain version milestone. ## One-Time Live Cutover diff --git a/test/release-governance.bats b/test/release-governance.bats index 531658b1..e1c60fb2 100644 --- a/test/release-governance.bats +++ b/test/release-governance.bats @@ -170,6 +170,20 @@ load 'vendor/bats-plugins/bats-assert/load' done } +@test "classification model keeps priority and status in project fields" { + run grep -F "classification_axis: 'type, legend:*, group:*, work:*, and pkg:* labels'" .continuum/release.yml + assert_success + + run grep -F "project_fields: 'priority and workflow status'" .continuum/release.yml + assert_success + + run grep -F '`priority:*`' docs/topics/contributing/triage.md + assert_failure + + run grep -F "Use Project fields for priority and workflow status." docs/topics/contributing/triage.md + assert_success +} + @test "issue and pull request templates preserve the scheduling invariant" { run grep -F 'Linked issue has exactly one milestone, named plain `vX.Y.Z`, and no `triage:*`, retired `lane:*`, or concrete-version scheduling label.' .github/pull_request_template.md assert_success diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 553f70d1..e0b46813 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -3639,6 +3639,14 @@ mod tests { "exactly one triage:* label; no milestone, retired lane:*, or concrete version label" ) ); + assert_eq!( + yaml_field(issue_model, "classification_axis").and_then(Yaml::as_str), + Some("type, legend:*, group:*, work:*, and pkg:* labels") + ); + assert_eq!( + yaml_field(issue_model, "project_fields").and_then(Yaml::as_str), + Some("priority and workflow status") + ); let issue_forms = [ ( From cd0dfd07863eea6cc2cb90263aeb855b63b8d7a4 Mon Sep 17 00:00:00 2001 From: James Ross Date: Fri, 24 Jul 2026 21:39:59 -0700 Subject: [PATCH 10/16] Fix: remove nested Cargo build from Bats --- test/ci-workflows.bats | 5 +++++ test/release-governance.bats | 7 ------- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/test/ci-workflows.bats b/test/ci-workflows.bats index 9b49dad6..240ee856 100644 --- a/test/ci-workflows.bats +++ b/test/ci-workflows.bats @@ -282,6 +282,11 @@ load 'vendor/bats-plugins/bats-assert/load' [ "$output" -ge 3 ] } +@test "repo Bats tests do not nest Cargo builds" { + run rg -n '^[[:space:]]*run cargo([[:space:]]|$)' test --glob '*.bats' + assert_failure +} + @test "repo Bats installer provisions command dependencies" { run grep -F "sudo apt-get install -y bats jq ripgrep" .github/actions/install-bats/action.yml assert_success diff --git a/test/release-governance.bats b/test/release-governance.bats index e1c60fb2..254eb04b 100644 --- a/test/release-governance.bats +++ b/test/release-governance.bats @@ -123,13 +123,6 @@ load 'vendor/bats-plugins/bats-assert/load' assert_success } -@test "release governance YAML is structurally valid" { - run cargo test --quiet --locked -p xtask tests::release_governance_yaml_is_structurally_valid -- --exact - assert_success - assert_output --partial "running 1 test" - assert_output --partial "1 passed; 0 failed" -} - @test "release profile uses plain version milestones as the sole schedule" { run grep -F "release_milestone_format: 'v{version}'" .continuum/release.yml assert_success From ce6cb70496766545b51b8c29535fa50e521ca0bd Mon Sep 17 00:00:00 2001 From: James Ross Date: Fri, 24 Jul 2026 21:41:49 -0700 Subject: [PATCH 11/16] Fix: require release sequence markers --- test/release-governance.bats | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/test/release-governance.bats b/test/release-governance.bats index 254eb04b..15ad6a6e 100644 --- a/test/release-governance.bats +++ b/test/release-governance.bats @@ -315,6 +315,14 @@ load 'vendor/bats-plugins/bats-assert/load' assert_success } +@test "release sequence ordering oracles require every marker" { + run grep -E '^[[:space:]]*END \{ exit !\(preflight && gate && prep && refresh && unchanged && tag && tagged && push && workflow && preflight < gate' test/release-governance.bats + assert_success + + run grep -E '^[[:space:]]*END \{ exit !\(sync && record && preflight && gate && clear && refresh && unchanged && synced && tag && sync < record' test/release-governance.bats + assert_success +} + @test "release runbook orders pre-tag and tag-specific guards" { run awk ' /Run a fresh `cargo xtask preflight`/{preflight=NR} @@ -326,7 +334,7 @@ load 'vendor/bats-plugins/bats-assert/load' /Run `cargo xtask release-guard/{tagged=NR} /Push the exact release tag only/{push=NR} /Monitor the tag-triggered workflow/{workflow=NR} - END { exit !(preflight < gate && gate < prep && prep < refresh && refresh < unchanged && unchanged < tag && tag < tagged && tagged < push && push < workflow) } + END { exit !(preflight && gate && prep && refresh && unchanged && tag && tagged && push && workflow && preflight < gate && gate < prep && prep < refresh && refresh < unchanged && unchanged < tag && tag < tagged && tagged < push && push < workflow) } ' docs/method/release-runbook.md assert_success @@ -385,7 +393,7 @@ load 'vendor/bats-plugins/bats-assert/load' /test .*git rev-parse HEAD.*validated_head/{unchanged=NR} /test .*git rev-parse HEAD.*git rev-parse origin\/main/{synced=NR} /git tag -s vX.Y.Z/{tag=NR} - END { exit !(sync < record && record < preflight && preflight < gate && gate < clear && clear < refresh && refresh < unchanged && unchanged < synced && synced < tag) } + END { exit !(sync && record && preflight && gate && clear && refresh && unchanged && synced && tag && sync < record && record < preflight && preflight < gate && gate < clear && clear < refresh && refresh < unchanged && unchanged < synced && synced < tag) } ' RELEASE.md assert_success From a0eb7aa08b440e092aa3a39a796e97fbbd0a3c11 Mon Sep 17 00:00:00 2001 From: James Ross Date: Fri, 24 Jul 2026 21:43:14 -0700 Subject: [PATCH 12/16] Fix: compare GitHub repository paths case-insensitively --- CHANGELOG.md | 4 +++- xtask/src/main.rs | 28 ++++++++++++++++++++++++++-- 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 966adb35..ca55639a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,9 @@ The format is based on Keep a Changelog, and this project adheres to Semantic Ve with one plain `vX.Y.Z` milestone per planned release. Release guards now require that exact open milestone and block on its open issues; the release gate closes before the immutable tag is created. Priority and workflow status - remain Project fields rather than classification labels. + remain Project fields rather than classification labels. GitHub + owner/repository path comparisons treat letter casing as non-semantic while + preserving the origin-derived path. ## [0.3.0-alpha.1] - 2026-07-15 diff --git a/xtask/src/main.rs b/xtask/src/main.rs index e0b46813..9e2a9fcb 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -2037,7 +2037,7 @@ fn resolve_release_github_repository( let push_repository = parse_github_repository_remote(push_remote).ok_or_else(|| { format!("could not infer GitHub repository from origin push URL `{push_remote}`") })?; - if push_repository != origin_repository { + if !push_repository.eq_ignore_ascii_case(&origin_repository) { return Err(format!( "origin push repository `{push_repository}` does not match origin fetch repository `{origin_repository}`" )); @@ -2048,7 +2048,7 @@ fn resolve_release_github_repository( let repository = parse_github_repository_path(repository).ok_or_else(|| { format!("GITHUB_REPOSITORY `{repository}` is not a valid owner/repository path") })?; - if repository != origin_repository { + if !repository.eq_ignore_ascii_case(&origin_repository) { return Err(format!( "GITHUB_REPOSITORY `{repository}` does not match origin fetch and push repository `{origin_repository}`" )); @@ -4023,6 +4023,30 @@ mod tests { ); } + #[test] + fn release_repository_accepts_case_insensitive_fetch_and_push_paths() { + assert_eq!( + resolve_release_github_repository( + "https://github.com/FlyingRobots/Wesley.git", + "git@github.com:flyingrobots/wesley.git", + None, + ), + Ok("FlyingRobots/Wesley".to_string()) + ); + } + + #[test] + fn release_repository_accepts_case_insensitive_ambient_path() { + assert_eq!( + resolve_release_github_repository( + "https://github.com/flyingrobots/wesley.git", + "git@github.com:flyingrobots/wesley.git", + Some("FLYINGROBOTS/WESLEY"), + ), + Ok("flyingrobots/wesley".to_string()) + ); + } + #[test] fn release_repository_rejects_ambient_value_mismatching_origin() { let error = resolve_release_github_repository( From 0e3330856d7e1ba7fcfbe37ef31c229d938a9a0e Mon Sep 17 00:00:00 2001 From: James Ross Date: Fri, 24 Jul 2026 21:44:15 -0700 Subject: [PATCH 13/16] Fix: separate planning state from classification labels --- docs/architecture/hosts.md | 7 ++++--- test/docs-planning-boundary.bats | 11 +++++++++++ 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/docs/architecture/hosts.md b/docs/architecture/hosts.md index b87c954f..a2cc7056 100644 --- a/docs/architecture/hosts.md +++ b/docs/architecture/hosts.md @@ -39,6 +39,7 @@ packages are not part of the Rust-native product spine. ## Planning Boundary Host maturity and release scheduling are not tracked by README progress tables -or filesystem milestone docs. Use GitHub Issues, Milestones, Projects, and -classification labels for live planning state. Plain `vX.Y.Z` milestones are -the release-scheduling authority. +or filesystem milestone docs. Use GitHub Issues, Milestones, and Projects for +live planning state. Plain `vX.Y.Z` milestones are the sole release-scheduling +authority. Classification labels are work metadata, not planning-state surfaces +or scheduling authority. diff --git a/test/docs-planning-boundary.bats b/test/docs-planning-boundary.bats index d6f1c788..8f0d6200 100644 --- a/test/docs-planning-boundary.bats +++ b/test/docs-planning-boundary.bats @@ -19,6 +19,17 @@ load 'vendor/bats-plugins/bats-assert/load' assert_failure } +@test "host planning boundary separates planning surfaces from labels" { + run rg -U "Use GitHub Issues, Milestones, and Projects for\\s+live planning state\\." docs/architecture/hosts.md + assert_success + + run rg -U "Plain \`vX\\.Y\\.Z\` milestones are the sole release-scheduling\\s+authority\\." docs/architecture/hosts.md + assert_success + + run rg -U "Classification labels are work metadata, not planning-state surfaces\\s+or scheduling authority\\." docs/architecture/hosts.md + assert_success +} + @test "repo-owned progress automation is absent" { run test ! -e .github/workflows/progress.yml assert_success From 0c955bd4c1b8c7fd97e1a9eed29a2146760bc54d Mon Sep 17 00:00:00 2001 From: James Ross Date: Fri, 24 Jul 2026 21:45:41 -0700 Subject: [PATCH 14/16] Fix: normalize release procedure list numbering --- docs/CRATES_IO_RELEASE.md | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/docs/CRATES_IO_RELEASE.md b/docs/CRATES_IO_RELEASE.md index 3d1e0f9e..7ae0d464 100644 --- a/docs/CRATES_IO_RELEASE.md +++ b/docs/CRATES_IO_RELEASE.md @@ -255,15 +255,15 @@ Packaging sanity must fail on: 2. Stage all release-prep changes. 3. Create exactly one release-prep commit on a release branch: -```bash -git commit -m "chore(release): vX.Y.Z" -``` + ```bash + git commit -m "chore(release): vX.Y.Z" + ``` -For prereleases: + For prereleases: -```bash -git commit -m "chore(release): vX.Y.Z-alpha.1" -``` + ```bash + git commit -m "chore(release): vX.Y.Z-alpha.1" + ``` 4. Land the release-prep change through the protected `main` branch. 5. Fetch `origin/main` and tags. @@ -286,15 +286,15 @@ git commit -m "chore(release): vX.Y.Z-alpha.1" 1. Confirm the release-gate issue is closed, then create exactly one signed tag on the synced `main` commit: -```bash -git tag -s vX.Y.Z -m "release: vX.Y.Z" -``` + ```bash + git tag -s vX.Y.Z -m "release: vX.Y.Z" + ``` -For prereleases: + For prereleases: -```bash -git tag -s vX.Y.Z-alpha.1 -m "release: vX.Y.Z-alpha.1" -``` + ```bash + git tag -s vX.Y.Z-alpha.1 -m "release: vX.Y.Z-alpha.1" + ``` 2. Verify the tag points at the synced `main` commit. 3. Verify the tag signature. From b5404a6b10a2573580008bb0533ddca63f728f5a Mon Sep 17 00:00:00 2001 From: James Ross Date: Fri, 24 Jul 2026 21:47:21 -0700 Subject: [PATCH 15/16] Fix: document unconditional repository Bats checks --- docs/ci.md | 41 ++++++++++++----------------------------- test/README.md | 2 +- test/ci-workflows.bats | 8 ++++++++ 3 files changed, 21 insertions(+), 30 deletions(-) diff --git a/docs/ci.md b/docs/ci.md index 632c81b2..e236131d 100644 --- a/docs/ci.md +++ b/docs/ci.md @@ -4,7 +4,8 @@ This repository uses multiple GitHub Actions workflows to keep the codebase heal ## Workflows Overview -- `ci.yml` — Main pipeline. Installs deps, runs unit tests, and executes a small set of repository-level Bats tests (server/docs/CI checks) when relevant. +- `ci.yml` — Main pipeline. Installs dependencies, runs unit tests, and executes + the repository-level Bats suites on every covered run. - `rust-native.yml` — Rust product preflight for the native compiler kernel and CLI. - `preflight.yml` — Repository hygiene checks (docs links, dependency boundaries, ESLint purity, license audit). - Package workflows — focused checks for retained non-compiler packages such as Holmes. @@ -29,7 +30,7 @@ We provide a reusable workflow to install Bats and jq: Use this anywhere Bats-based tests run (Linux runners). -## Repo-level Bats Tests (Gated) +## Repo-level Bats Tests In `ci.yml`, we run a concise set of repository-level Bats suites covering: @@ -37,37 +38,19 @@ In `ci.yml`, we run a concise set of repository-level Bats suites covering: - Docs planning-boundary guards - CI YAML invariants -To keep CI lean, these tests are gated via a simple diff check and only execute -when relevant files change (paths matching `scripts/serve-static.mjs`, -`scripts/generate-ir-fixtures.mjs`, `test/serve-static*`, -`test/docs-planning-boundary.bats`, `test/domain-empty-boundary.bats`, -`test/ir-fixtures.bats`, or `test/ci-*`). - -Example gating snippet used in `ci.yml`: - -```yaml -- name: Detect changes for repo Bats tests - id: changelog - run: | - RANGE="${{ github.event.before }}..${{ github.sha }}" - if [ "${{ github.event_name }}" = "pull_request" ] && [ -n "${{ github.event.pull_request.base.sha }}" ]; then - RANGE="${{ github.event.pull_request.base.sha }}..${{ github.sha }}" - fi - CHANGED=$(git diff --name-only "$RANGE" || true) - NEED=false - echo "$CHANGED" | grep -E -q '^(scripts/serve-static\\.mjs|test/serve-static|scripts/generate-ir-fixtures\\.mjs|test/docs-planning-boundary\\.bats|test/domain-empty-boundary\\.bats|test/ir-fixtures\\.bats|test/ci-)' && NEED=true || true - echo "RUN_BATS=$NEED" >> $GITHUB_ENV -- name: Repo Bats tests - if: ${{ env.RUN_BATS == 'true' }} - env: - BATS_LIB_PATH: test/vendor - run: bats test/serve-static*.bats test/docs-planning-boundary.bats test/domain-empty-boundary.bats test/ir-fixtures.bats test/ci-*.bats -``` +Repository-level Bats suites run unconditionally in CI. The explicit file list +and per-file timeout in `.github/workflows/ci.yml` are the canonical execution +manifest. ### Run these locally ```bash pnpm run setup:bats-plugins BATS_LIB_PATH=test/vendor \ - bats test/serve-static*.bats test/docs-planning-boundary.bats test/domain-empty-boundary.bats test/ir-fixtures.bats test/ci-*.bats + bats test/serve-static*.bats \ + test/docs-planning-boundary.bats \ + test/domain-empty-boundary.bats \ + test/ir-fixtures.bats \ + test/release-governance.bats \ + test/ci-*.bats ``` diff --git a/test/README.md b/test/README.md index a431d4bc..67638fc4 100644 --- a/test/README.md +++ b/test/README.md @@ -48,7 +48,7 @@ GitHub Actions runs the relevant subsets: ### Repo-level Bats tests (server/docs/CI checks) -These small suites are gated in CI and only run when relevant files change. To run locally: +Repository-level Bats suites run unconditionally in CI. To run them locally: ```bash pnpm run setup:bats-plugins diff --git a/test/ci-workflows.bats b/test/ci-workflows.bats index 240ee856..c3393c9a 100644 --- a/test/ci-workflows.bats +++ b/test/ci-workflows.bats @@ -558,6 +558,14 @@ load 'vendor/bats-plugins/bats-assert/load' run grep -F 'name: Repo Bats tests (unit/docs/ci checks only)' .github/workflows/ci.yml assert_success + + for doc_path in docs/ci.md test/README.md; do + run grep -F 'Repository-level Bats suites run unconditionally in CI.' "$doc_path" + assert_success + + run rg -n 'RUN_BATS|only run when relevant files change|Repo-level Bats Tests \\(Gated\\)' "$doc_path" + assert_failure + done } @test "release crates workflow authenticates release guard GitHub API checks" { From 412210b0427bcaa08d25070d8dddba2780f0745b Mon Sep 17 00:00:00 2001 From: James Ross Date: Fri, 24 Jul 2026 21:50:15 -0700 Subject: [PATCH 16/16] Fix: align reusable Bats dependencies --- .github/workflows/install-bats.yml | 20 -------------------- CHANGELOG.md | 3 +++ docs/ci.md | 10 ++++++---- test/ci-workflows.bats | 9 +++++++++ 4 files changed, 18 insertions(+), 24 deletions(-) delete mode 100644 .github/workflows/install-bats.yml diff --git a/.github/workflows/install-bats.yml b/.github/workflows/install-bats.yml deleted file mode 100644 index b5f908d6..00000000 --- a/.github/workflows/install-bats.yml +++ /dev/null @@ -1,20 +0,0 @@ -name: install-bats - -on: - workflow_call: - -permissions: - contents: read - -jobs: - install: - runs-on: ubuntu-latest - steps: - - name: Harden runner - uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 - with: - egress-policy: audit - - name: Install bats and jq - run: | - sudo apt-get update - sudo apt-get install -y bats jq diff --git a/CHANGELOG.md b/CHANGELOG.md index ca55639a..ca606b6a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,9 @@ The format is based on Keep a Changelog, and this project adheres to Semantic Ve remain Project fields rather than classification labels. GitHub owner/repository path comparisons treat letter casing as non-semantic while preserving the origin-derived path. +- Consolidated repository Bats dependency installation on the checked-in + composite action, including Bats, jq, and ripgrep, and retired the duplicate + workflow that could not provision tools into its caller job. ## [0.3.0-alpha.1] - 2026-07-15 diff --git a/docs/ci.md b/docs/ci.md index e236131d..e95e6707 100644 --- a/docs/ci.md +++ b/docs/ci.md @@ -19,16 +19,18 @@ Workflow names distinguish product checks from compatibility checks: ## Reusable Pieces -### Install Bats (reusable workflow) +### Install Bats Dependencies -We provide a reusable workflow to install Bats and jq: +Repository workflows install Bats, jq, and ripgrep through one checked-in +composite action. Run it after `actions/checkout` so the local action is +available: ```yaml - name: Install Bats - uses: flyingrobots/wesley/.github/workflows/install-bats.yml@main + uses: ./.github/actions/install-bats ``` -Use this anywhere Bats-based tests run (Linux runners). +Use this action anywhere repository Bats suites run on Ubuntu. ## Repo-level Bats Tests diff --git a/test/ci-workflows.bats b/test/ci-workflows.bats index c3393c9a..73044f23 100644 --- a/test/ci-workflows.bats +++ b/test/ci-workflows.bats @@ -290,6 +290,15 @@ load 'vendor/bats-plugins/bats-assert/load' @test "repo Bats installer provisions command dependencies" { run grep -F "sudo apt-get install -y bats jq ripgrep" .github/actions/install-bats/action.yml assert_success + + run test ! -e .github/workflows/install-bats.yml + assert_success + + run grep -F "uses: ./.github/actions/install-bats" docs/ci.md + assert_success + + run rg -n "\\.github/workflows/install-bats\\.yml" docs .github + assert_failure } @test "cert-shipme certifies only landed target-branch commits" {