diff --git a/.continuum/release.yml b/.continuum/release.yml new file mode 100644 index 00000000..fefb77b3 --- /dev/null +++ b/.continuum/release.yml @@ -0,0 +1,129 @@ +schema: 1 +repo: + name: wesley + owner: flyingrobots + primary_artifact: rust-crates + public_binary: wesley + product_boundary: domain-free-graphql-to-ir + +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}' + +version_sources: + - path: crates/wesley-core/Cargo.toml + name: wesley-core + type: cargo-manifest + field: package.version + required: true + published: true + - path: crates/wesley-emit-codec/Cargo.toml + name: wesley-emit-codec + type: cargo-manifest + field: package.version + required: true + published: true + - path: crates/wesley-emit-rust/Cargo.toml + name: wesley-emit-rust + type: cargo-manifest + field: package.version + required: true + published: true + - path: crates/wesley-emit-typescript/Cargo.toml + name: wesley-emit-typescript + type: cargo-manifest + field: package.version + required: true + published: true + - path: crates/wesley-cli/Cargo.toml + name: wesley-cli + type: cargo-manifest + field: package.version + required: true + published: true + - path: crates/wesley-holmes/Cargo.toml + name: wesley-holmes + type: cargo-manifest + field: package.version + required: true + published: false + - path: package.json + name: root-private-package + type: json + field: version + required: true + published: false + +docs: + changelog: CHANGELOG.md + front_door: + - README.md + - docs/README.md + - docs/GUIDE.md + - docs/ENTRYPOINTS.md + architecture: + - docs/ARCHITECTURE.md + - docs/TECHNICAL_TEARDOWN.md + user_docs: + - docs/GUIDE.md + - docs/site/ + - docs/topics/ + - docs/reference/ + - docs/releases/ + operator_docs: + - docs/method/release.md + - docs/method/release-runbook.md + - docs/CRATES_IO_RELEASE.md + - docs/governance/RELEASE_POLICY.md + - docs/governance/RELEASE_CHECKLIST.md + contributor_docs: + - AGENTS.md + - CONTRIBUTING.md + - docs/METHOD.md + - docs/topics/contributing/triage.md + +validation: + docs_check: cargo xtask docs-check + prep: cargo xtask release-prep-guard --version {version} + preflight: cargo xtask preflight + rust_advisory_audit: cargo audit + release_check: cargo xtask release-check + package: cargo xtask package-crates --version {version} + tagged_guard: cargo xtask release-guard --tag v{version} + legacy_when_needed: cargo xtask legacy-preflight + +workflows: + publish: .github/workflows/release-crates.yml + publish_trigger: tag-push + autotag: none + +publish: + manual_dispatch_required: false + github_release: true + source_ref: 'v{version}' + registries: + - name: crates.io + packages: + - wesley-core + - wesley-emit-codec + - wesley-emit-rust + - wesley-emit-typescript + - wesley-cli + verify: cargo info {package}@{version} + +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}' + +release_evidence: + internal_packet: docs/method/releases/v{version}/release.md + verification: docs/method/releases/v{version}/verification.md + user_notes: docs/releases/v{version}.md diff --git a/.github/workflows/cert-shipme.yml b/.github/workflows/cert-shipme.yml index d0414df5..88cf1b50 100644 --- a/.github/workflows/cert-shipme.yml +++ b/.github/workflows/cert-shipme.yml @@ -2,13 +2,7 @@ name: SHIPME Certificate on: push: - branches: [main, dev, 'feat/*', 'fix/*', 'milestone/*'] - paths: - - 'packages/**' - - 'scripts/prepare-shipme-cert-fixture.mjs' - - '.github/**' - - '.github/workflows/cert-shipme.yml' - pull_request: + branches: [main] paths: - 'packages/**' - 'scripts/prepare-shipme-cert-fixture.mjs' @@ -21,11 +15,6 @@ permissions: jobs: cert: runs-on: ubuntu-latest - permissions: - actions: read - contents: read - issues: write - pull-requests: write steps: - name: Harden runner uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 @@ -65,111 +54,3 @@ jobs: with: name: SHIPME path: SHIPME.md - - - name: Post SHIPME badge to PR - if: github.event_name == 'pull_request' - id: badge - run: | - echo "BADGE=[SHIPME] PASS · HOLMES fixture · sha ${GITHUB_SHA::7}" >> $GITHUB_OUTPUT - - - name: Wait for HOLMES suite comment - if: github.event_name == 'pull_request' - id: holmes_comment_wait - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 - with: - script: | - const marker = ``; - const started = Date.now(); - const workflowId = 'wesley-holmes.yml'; - const timeoutMs = 2 * 60 * 60 * 1000; - const pollIntervalMs = 15 * 1000; - const maxNoRunFound = 20; - let noRunFoundCount = 0; - - while (Date.now() - started < timeoutMs) { - const comments = await github.paginate(github.rest.issues.listComments, { - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: context.issue.number, - per_page: 100, - }); - - const holmesComment = comments.find( - (comment) => comment.user?.login === 'github-actions[bot]' && comment.body?.includes(marker) - ); - - if (holmesComment) { - core.setOutput('ready', 'true'); - return; - } - - const workflowRunsResponse = await github.request( - 'GET /repos/{owner}/{repo}/actions/workflows/{workflow_id}/runs', - { - owner: context.repo.owner, - repo: context.repo.repo, - workflow_id: workflowId, - event: 'pull_request', - head_sha: context.sha, - per_page: 20 - } - ); - - const currentRun = workflowRunsResponse.data.workflow_runs - .sort((left, right) => new Date(right.created_at) - new Date(left.created_at))[0]; - - if (!currentRun) { - noRunFoundCount += 1; - if (noRunFoundCount > maxNoRunFound) { - core.setOutput('ready', 'false'); - core.setFailed(`No wesley-holmes.yml run found for ${context.sha} after ${maxNoRunFound} polls.`); - return; - } - - await new Promise((resolve) => setTimeout(resolve, pollIntervalMs)); - continue; - } - - noRunFoundCount = 0; - - if (currentRun?.status === 'completed') { - core.setOutput('ready', 'false'); - core.setFailed( - `HOLMES suite workflow ${currentRun.id} for ${context.sha} finished with conclusion ${currentRun.conclusion || 'unknown'} before posting the Holmes PR comment.` - ); - return; - } - - await new Promise((resolve) => setTimeout(resolve, pollIntervalMs)); - } - - core.setOutput('ready', 'false'); - core.setFailed(`HOLMES suite comment for ${context.sha} did not appear within the 2 hour coordination window.`); - - - name: Create/Update PR Comment - if: github.event_name == 'pull_request' && github.actor != 'dependabot[bot]' && steps.holmes_comment_wait.outputs.ready == 'true' - continue-on-error: true - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 - with: - script: | - const badge = process.env.BADGE || '${{ steps.badge.outputs.BADGE }}'; - const marker = ''; - const body = `${marker}\n### 🚢 SHIPME\n\n${badge}\n`; - try { - const comments = await github.paginate(github.rest.issues.listComments, { - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: context.issue.number, - per_page: 100, - }); - const botComment = comments.find( - (c) => c.user?.login === 'github-actions[bot]' && c.body?.includes(marker) - ); - if (botComment) { - await github.rest.issues.updateComment({ owner: context.repo.owner, repo: context.repo.repo, comment_id: botComment.id, body }); - } else { - await github.rest.issues.createComment({ owner: context.repo.owner, repo: context.repo.repo, issue_number: context.issue.number, body }); - } - } catch (error) { - core.warning(`Could not post SHIPME badge to PR: ${error.message}`); - } diff --git a/.github/workflows/release-crates.yml b/.github/workflows/release-crates.yml index 066a7ac3..cf55485c 100644 --- a/.github/workflows/release-crates.yml +++ b/.github/workflows/release-crates.yml @@ -223,8 +223,17 @@ jobs: run: | set -euo pipefail version="${GITHUB_REF_NAME#v}" - for crate in wesley-core wesley-emit-rust wesley-emit-typescript wesley-cli; do - cargo info "${crate}@${version}" >/dev/null + for crate in wesley-core wesley-emit-codec wesley-emit-rust wesley-emit-typescript wesley-cli; do + for attempt in $(seq 1 30); do + if cargo info "${crate}@${version}" >/dev/null 2>&1; then + break + fi + if [ "$attempt" -eq 30 ]; then + echo "${crate}@${version} did not become visible on crates.io in time" >&2 + exit 1 + fi + sleep 10 + done done - name: Finalize GitHub Release diff --git a/CHANGELOG.md b/CHANGELOG.md index 6572f6cb..6914b9f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,10 @@ hash`, and `schema operations`. ### Changed +- **Release lifecycle profile**: Added a repo-local `.continuum/release.yml` + release profile and expanded the Wesley release doctrine/runbook around + thesis, scope, goalposts, immutable tagged-main publication, verification, + and retrospective evidence. - **Release documentation gate**: The release runbook, release policy, and human sign-off checklist now require a `docs/topics/` accuracy and coverage audit before tagging, with minimum 90% accuracy and 90% coverage floors. @@ -38,6 +42,9 @@ hash`, and `schema operations`. manifest first, computes changed schema sets with `wesley config changed-schemas`, runs schema-scoped matrix jobs, and keeps per-schema report artifacts grouped for one aggregate PR comment. +- **HOLMES distribution direction**: Documented tagged reusable GitHub Actions + workflows plus copy/paste templates as the user-facing HOLMES install path, + with GitHub App delivery deferred to future identity or Checks API needs. - **Extension documentation**: Added current project-manifest and module authoring references, and clarified that `wesley.config.mjs` and the dynamic JavaScript module loader are retired from generic Wesley core. @@ -52,6 +59,28 @@ changed-schemas`, runs schema-scoped matrix jobs, and keeps per-schema report ### Fixed +- **Release crate visibility check**: The tag-triggered Release Crates workflow + now verifies crates.io visibility for `wesley-emit-codec` along with the rest + of the published Rust crate set, with bounded registry-index retries before + finalizing the GitHub Release. +- **Release version-source enforcement**: `cargo xtask release-prep-guard`, + `cargo xtask release-guard`, `cargo xtask package-crates`, and + `cargo xtask publish-crates` now reject root `package.json` and unpublished + required Cargo manifest version drift in addition to published Rust crate + manifest drift. +- **Release issue blocker selection**: Release guards now rely on exact-version + issue text and `vX.Y.Z` labels for pre-tag blockers while allowing the + `Release: vX.Y.Z` gate issue to remain open for post-publication evidence and + closeout. +- **Release advisory-audit profile**: The repo-local release profile now + declares the Rust advisory audit command alongside the other release + validation gates. +- **Release signpost profile coverage**: The repo-local release profile now + includes the public MkDocs source and guide page in user-doc signposts so + profile-driven audits cover public release wording. +- **Post-merge SHIPME certification**: `cert-shipme.yml` now runs only on + `main` pushes, so SHIPME certificates bind to the landed target-branch SHA + instead of racing PR-time HOLMES comments for a temporary merge SHA. - **Docs CLI checker determinism**: The docs command checker now reads the native command list from the Rust CLI source help text instead of invoking `cargo run`, so Node-only repository hygiene does not depend on Cargo diff --git a/docs/CRATES_IO_RELEASE.md b/docs/CRATES_IO_RELEASE.md index 25963d12..6a5c1775 100644 --- a/docs/CRATES_IO_RELEASE.md +++ b/docs/CRATES_IO_RELEASE.md @@ -1,12 +1,16 @@ # CRATES.IO RELEASE - + This is Wesley's official Rust release procedure. The distribution target is crates.io. The release authority is GitHub Actions. Humans prepare commits and tags; GitHub Actions performs the publish. +The repo-local release profile is [`.continuum/release.yml`](../.continuum/release.yml). +It declares the publish crate set, version sources, signposts, and verification +commands that this procedure must match. + ## Non-Negotiable Policy 1. Releases must only be performed by GitHub Actions. @@ -86,6 +90,7 @@ The `release-gauntlet` job must verify: - tag commit is reachable from `origin/main` - every published `Cargo.toml` version matches the tag - every internal Wesley dependency version matches the tag +- root `package.json` version matches the tag - every publishable crate has the minimum package file set - root `README.md` exists - root `CHANGELOG.md` contains release notes for the exact version diff --git a/docs/architecture/holmes-integration.md b/docs/architecture/holmes-integration.md index 42041440..596860a0 100644 --- a/docs/architecture/holmes-integration.md +++ b/docs/architecture/holmes-integration.md @@ -113,6 +113,20 @@ Manifest `commentMode` controls the final PR comment behavior: | `append` | Create a new PR comment for each run. | | `silent` | Run analysis but do not write a PR comment. | +## Distribution Boundary + +HOLMES should be packaged for external users as a tagged reusable workflow with +documented workflow templates. That keeps execution inside the consumer +repository's GitHub Actions environment, where checkout state, permissions, +artifacts, and logs are inspectable by the repository owner. + +A GitHub App is not the first-class delivery mechanism. It remains deferred +unless HOLMES needs a durable certifying identity, Checks API ownership, +cross-repo dashboards, or organization-level policy orchestration. If such an +app exists later, it should consume evidence produced by repository-local +Actions runs rather than becoming a hidden compiler or source of target +semantics. + ## Dashboard Artifact The workflow uploads `docs/holmes-dashboard` as a dashboard template and diff --git a/docs/governance/RELEASE_CHECKLIST.md b/docs/governance/RELEASE_CHECKLIST.md index b009a36f..4b551d91 100644 --- a/docs/governance/RELEASE_CHECKLIST.md +++ b/docs/governance/RELEASE_CHECKLIST.md @@ -10,7 +10,7 @@ Automated checks are not listed here — those run inside creating a tag, 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, and 23 from the enforcement matrix, which require +checks 7, 10, 13, 18, 22, 23, and 24 from the enforcement matrix, which require human judgment. See [`RELEASE_POLICY.md`](RELEASE_POLICY.md) for the full enforcement matrix @@ -48,6 +48,12 @@ and rationale. workflows are met. Any stale topic claim, obsolete instruction, missing topic, or missing authoritative link was corrected before tagging. +- [ ] **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 + evidence, and retrospective/evidence location. Any planned work that did + not ship was moved, cut, or acknowledged before tagging. + - [ ] **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 diff --git a/docs/governance/RELEASE_POLICY.md b/docs/governance/RELEASE_POLICY.md index 7f3af310..166d5cbe 100644 --- a/docs/governance/RELEASE_POLICY.md +++ b/docs/governance/RELEASE_POLICY.md @@ -6,6 +6,12 @@ This document is the canonical gate policy for Wesley releases. Every release must clear all automated checks and all human sign-offs before a tag is considered valid and the publish workflow is permitted to run. +The release doctrine lives in [`docs/method/release.md`](../method/release.md). +The repo-local release profile lives in +[`../../.continuum/release.yml`](../../.continuum/release.yml) and declares the +version sources, publish crate set, signposts, workflows, and verification +commands this policy protects. + ## Enforcement Release gates are split between automated machinery and human review. Neither @@ -31,7 +37,7 @@ lacks the human sign-off is not a valid release, and vice versa. | 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 all `Cargo.toml` manifests | parse | | +| 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 | | @@ -50,6 +56,7 @@ lacks the human sign-off is not a valid release, and vice versa. | 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 @@ -88,8 +95,11 @@ artifacts. ### Check 5: Version Lockstep -All `Cargo.toml` manifests for published crates must declare the same version -as the release tag. Workspace members are not permitted to drift independently. +All release version sources declared in `.continuum/release.yml` must declare +the same version as the release tag. Today that means every published crate +`Cargo.toml` manifest, the unpublished `crates/wesley-holmes/Cargo.toml` +manifest, and the private root `package.json`. Workspace members are not +permitted to drift independently. ### Check 6: Changelog @@ -226,6 +236,15 @@ enough before tagging. Post-publish facts may live in the GitHub Release, workflow logs, and crates.io registry; they should not require a manual 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 +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, +post-publication verification, and fallout issue path. + ## Policy Violations If a release is discovered to have shipped in violation of this policy: diff --git a/docs/method/release-runbook.md b/docs/method/release-runbook.md index 421b4fcc..4391a4ea 100644 --- a/docs/method/release-runbook.md +++ b/docs/method/release-runbook.md @@ -1,11 +1,17 @@ # Release Runbook + + Use this runbook when a release has already been shaped in `docs/method/releases/vX.Y.Z/release.md` and is ready for pre-flight. This is intentionally the execution layer, not the doctrine layer. The release doctrine lives in `docs/method/release.md`. +The repo-local release profile lives in [`.continuum/release.yml`](../../.continuum/release.yml). +Use it as the source for version sources, publish crates, signposts, workflow +names, and verification commands. + ## Abort Conditions - Never guess. Never claim success for anything you did not directly verify. @@ -26,6 +32,7 @@ Before changing anything, determine and record: - package manager and lockfile authority - all version-bearing manifests - all publishable units +- release profile presence and obvious agreement with the current repo - latest reachable semver tag matching `v*` - current branch - exact sync state versus `origin/main` @@ -114,9 +121,26 @@ CI state. 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. + 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. + +## Phase 5: 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. +7. Write or refresh the next release thesis before making the next planned + train active. ## Evidence @@ -131,3 +155,4 @@ minimum include: - registry URLs - `docs/topics/` accuracy and coverage scores, with links to any corrections - any non-blocking warnings +- retrospective summary and fallout issue links diff --git a/docs/method/release.md b/docs/method/release.md index e307795a..1e40eef8 100644 --- a/docs/method/release.md +++ b/docs/method/release.md @@ -1,12 +1,165 @@ # Release + + Releases happen when externally meaningful behavior changes. +Wesley follows the Continuum release spine, adapted for this repository's +actual shape: a domain-free Rust compiler/toolchain that publishes crates from +signed tags on synced `main`. A release is not a version bump. A release is a +promise made visible. + +The repo-local mechanics live in [`.continuum/release.yml`](../../.continuum/release.yml). +This doctrine defines the standard; the profile defines the boring facts that +automation and reviewers should enforce. + +## Doctrine + +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. +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. +6. **Synchronized metadata**: every version source declared in the release + profile agrees. +7. **Updated signposts**: changelog, README, guide, architecture, topics, + release notes, operator docs, and maintainer docs change when their truth + changes. +8. **Executable gates**: release law lives in `cargo xtask` checks and GitHub + Actions, not only in prose. +9. **Publication from the tag**: crates, GitHub Release notes, and registry + 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. +12. **Learning**: planned releases end with a retrospective and fallout issues. + +## Lifecycle + +Wesley releases move through this lifecycle: + +```text +planned + -> active + -> release-prep + -> merged + -> tagged + -> published + -> verified + -> retrospected + -> closed +``` + +### 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. + +### 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. + +### 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. + +### 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. + +### 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. + +```bash +git tag -s vX.Y.Z -m "release: vX.Y.Z" +``` + +### published + +A release is published when `.github/workflows/release-crates.yml` checks out +the tag, verifies tag/metadata/main reachability, builds artifacts from the tag, +publishes every configured crate, and creates/finalizes the GitHub Release. + +### verified + +A release is verified when public crates.io visibility is confirmed for every +published crate, the installed `wesley` CLI launches, the GitHub Release is +visible, and release evidence is captured. + +### retrospected + +A release is retrospected when released work, unreleased work, +plan-versus-actual scope, repeatable wins, concrete improvements, fallout +issues, and the next release recommendation are recorded. + +### closed + +A release is closed when scoped work is closed, moved, or explicitly cut; +fallout issues are triaged; the release milestone is closed; the next thesis +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, + publication evidence, and retrospective. +- **Patch release**: compatible bug fixes, packaging fixes, docs corrections + tied to current behavior, and narrow operator workflow improvements. Requires + a short patch thesis, changelog entry, validation, publication evidence, and a + lightweight retrospective. +- **Emergency or security release**: urgent production, security, data-loss, + broken package, or severe operator-impacting fixes. Planning may be + abbreviated, but immutable tags, proportional validation, verification, + retrospective, and fallout issues remain mandatory. +- **Prerelease**: alpha, beta, preview, or release-candidate crates. Prereleases + must not be treated as stable signposts unless promoted through a release + decision. +- **Docs-only release**: allowed only when the public release surface includes + docs or release notes consumers rely on. Runtime behavior changes must be + explicitly marked as absent. + +## Version Selection + +Wesley uses SemVer. + +- **Patch**: compatible fixes, dependency updates without public behavior + change, documentation corrections that describe existing behavior, and narrow + release-tooling fixes. +- **Minor**: new compatible CLI commands, public workflows, emitter behavior, + configuration options, or additive APIs. +- **Major**: incompatible CLI flag/default/output changes, public API removal, + package entrypoint changes, storage/IR schema changes consumers must handle, + or support-boundary removals. +- **Prerelease**: early artifacts without stable guarantees. + ## Shaped Release -A shaped release is not just a tag plus a changelog edit. It is a deliberate -packet that says what is shipping, why this version number is correct, which -users benefit, and what they need to do next. +A shaped release is a deliberate packet that says what is shipping, why this +version number is correct, which users benefit, and what they need to do next. Required release artifacts: @@ -28,19 +181,55 @@ Required release artifacts: `CHANGELOG.md` remains the ledger. The user-facing guided release surface lives in `docs/releases/`. -## Scope +## Scope Model -Releases aggregate shipped work. They do not create version-scoped filesystem -backlog directories, and implementation issues stay in goalpost milestones. -Release membership is tracked with concrete release lane labels such as -`v0.2.0`; release milestones hold release-gate issues. +Every planned release records three scope buckets: + +- **Must-ship**: work that defines the release. +- **May-slip**: valuable work that may move without invalidating the thesis. +- **Explicitly not included**: work people might assume is included but is not. The release design names and justifies the intended version before tagging. 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. -## Default +## Goalposts 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. + +## Signposts + +Update every signpost whose truth changed since the previous public tag: + +- `CHANGELOG.md` +- `README.md` +- `docs/README.md` +- `docs/GUIDE.md` +- `docs/ENTRYPOINTS.md` +- `docs/ARCHITECTURE.md` +- `docs/TECHNICAL_TEARDOWN.md` +- `docs/site/` +- `docs/topics/` +- `docs/reference/` +- `docs/releases/vX.Y.Z.md` +- `docs/method/releases/vX.Y.Z/` +- `docs/method/release.md` +- `docs/method/release-runbook.md` +- `docs/CRATES_IO_RELEASE.md` +- `docs/governance/RELEASE_POLICY.md` +- `docs/governance/RELEASE_CHECKLIST.md` +- `docs/METHOD.md`, `CONTRIBUTING.md`, and `AGENTS.md` when process truth + changes + +The `docs/topics/` audit is mandatory before tagging. It must reach at least +90% accuracy and 90% coverage for release-relevant contributor and operator +workflows. + +## Defaults - Not every cycle is a release. - Every cycle still updates the living docs honestly. @@ -51,11 +240,17 @@ themselves. ## Sequence 1. Shape the release in `docs/method/releases/vX.Y.Z/release.md`. -2. Accept the release scope and version justification before tagging. -3. Draft the user-facing release notes in `docs/releases/vX.Y.Z.md`. -4. Run the sequential pre-flight in `docs/method/release-runbook.md`. -5. Tag, publish, and verify delivery directly. -6. Ship sync repo-level surfaces that the release changed. +2. Confirm the release profile still matches the repo. +3. Accept the release thesis, scope, goalposts, 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. ## User-Facing Release Notes @@ -76,3 +271,17 @@ If no migration is required, say `No migration required.` explicitly. The doctrine lives here. The command-by-command, abort-fast release procedure lives in `docs/method/release-runbook.md` so it can become more explicit or automated later without bloating the core doctrine. + +## Non-Negotiables + +```text +No planned release without a thesis. +No release-prep PR without scope reconciliation. +No tag that does not point at the reviewed synced main commit. +No moving public tags. +No publishing from untagged moving source. +No silent version/profile mismatch. +No release without post-publication verification. +No planned release train after publication without a retrospective. +No domain-specific behavior in Wesley core release scope. +``` diff --git a/docs/topics/README.md b/docs/topics/README.md index 1f607434..fbdfd04a 100644 --- a/docs/topics/README.md +++ b/docs/topics/README.md @@ -40,7 +40,7 @@ short path to the authoritative surface. | Interpret GitHub Actions checks. | [CI Workflows](./ci-workflows.md) | `docs/ci.md`, `.github/workflows/` | | Understand HOLMES CI and PR comments. | [HOLMES CI](./holmes-ci.md) | `.github/workflows/wesley-holmes.yml`, `docs/architecture/` | | Work with assurance evidence and policies. | [Assurance Evidence](./assurance-evidence.md) | `docs/holmes-policy-spec.md` | -| Prepare or judge a release. | [Releases](./releases.md) | `docs/method/release-runbook.md`, `docs/governance/` | +| Prepare or judge a release. | [Releases](./releases.md) | `.continuum/release.yml`, `docs/method/release.md`, `docs/governance/` | | Refresh docs before a release tag. | [Docs Maintenance](./docs-maintenance.md) | `docs/governance/RELEASE_POLICY.md#check-23-docstopics-accuracy-and-coverage-gate` | ### Contributor Process diff --git a/docs/topics/assurance-evidence.md b/docs/topics/assurance-evidence.md index 6117569e..37422b34 100644 --- a/docs/topics/assurance-evidence.md +++ b/docs/topics/assurance-evidence.md @@ -18,12 +18,15 @@ and it does not create product semantics for GraphQL. | `docs/templates/holmes-policy/` | Policy templates for host contexts. | | `docs/architecture/holmes-*` | Architecture and integration notes. | | `.github/workflows/wesley-holmes.yml` | Pull request assurance workflow. | +| `.github/workflows/cert-shipme.yml` | Post-merge SHIPME certificate workflow. | ## Rules Of Thumb - Reports should expose unavailable or invalid evidence honestly. - Missing artifacts should not be hidden behind a passing workflow. - Policy and report quality are evidence questions, not compiler semantics. +- PR-time HOLMES evidence and post-merge SHIPME certification are distinct + gates. SHIPME records the landed `main` SHA, not the temporary PR merge SHA. - Domain-specific target facts should be produced by the owning target module. ## Useful Checks diff --git a/docs/topics/ci-workflows.md b/docs/topics/ci-workflows.md index 51e771e2..4e83ea5c 100644 --- a/docs/topics/ci-workflows.md +++ b/docs/topics/ci-workflows.md @@ -18,6 +18,7 @@ diff or running focused checks before a PR. | CodeQL / analysis | Static analysis for supported languages. | | Dependency review | Dependency risk in PRs. | | HOLMES workflow | Schema-selected assurance reports and PR comments. | +| SHIPME certificate | Post-merge evidence for the landed `main` SHA. | ## Local Mirrors @@ -43,6 +44,8 @@ BATS_LIB_PATH=test/vendor bats -t test/ci-workflows.bats ## Rules Of Thumb - A skipped HOLMES matrix can be correct when no schema set is selected. +- SHIPME certification is post-merge only. PR checks evaluate the proposed + integration; SHIPME certifies the commit that actually lands on `main`. - Browser, Bun, and Deno host experiment workflows are retired from Wesley. - Required checks should name the Rust product or repository hygiene surface they protect. diff --git a/docs/topics/holmes-ci.md b/docs/topics/holmes-ci.md index 473cb4ea..08efdec5 100644 --- a/docs/topics/holmes-ci.md +++ b/docs/topics/holmes-ci.md @@ -21,6 +21,22 @@ If no changed files are available, every schema set is selected. If no schema set matches the changed files, the HOLMES matrix is skipped rather than inventing work. +## Distribution Direction + +The user-facing HOLMES install path should be a tagged reusable GitHub Actions +workflow, backed by documented copy/paste workflow templates for repositories +that need full local ownership. Templates are examples and escape hatches; the +reusable workflow is the normal distribution surface because HOLMES is a CI +lane, not a single shell step. + +Consumers should pin released tags, not `main`, when they call a shared HOLMES +workflow or action. + +Do not make a GitHub App the first-class HOLMES install path. A future app may +be justified for dedicated `SHA-lock HOLMES` identity, Checks API ownership, +cross-repo dashboards, or organization policy orchestration. Until that need is +active, GitHub Actions should produce the evidence and PR comments directly. + ## Operator Notes - CodeRabbit and HOLMES are separate review surfaces. diff --git a/docs/topics/releases.md b/docs/topics/releases.md index 8a954933..7b1d389c 100644 --- a/docs/topics/releases.md +++ b/docs/topics/releases.md @@ -11,18 +11,22 @@ prepare release facts, but the tag must point at the merged `main` commit. Release state is split intentionally: -| Surface | Purpose | -| --------------------------------------------- | ---------------------------------------------- | -| GitHub label `vX.Y.Z` | Scheduled implementation and release-gate work | -| GitHub milestone `Release: vX.Y.Z` | Release-gate issue only | -| GitHub goalpost milestones | Implementation issues | -| `CHANGELOG.md` | Historical ledger of merged behavior | -| `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 | +| 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 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. ## Required Human Checks @@ -40,6 +44,20 @@ That includes: The `docs/topics/` gate means this directory must cover release-relevant contributor and operator workflows or link clearly to the current authority. +## Release Lifecycle + +Wesley uses the lifecycle defined in +[`docs/method/release.md`](../method/release.md): + +```text +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. + ## Pre-Tag Launch Pass After the release-prep PR lands on `main` but before creating the signed tag, @@ -85,6 +103,7 @@ synced `main` release commit. ## Related Authority +- [`.continuum/release.yml`](../../.continuum/release.yml) - [`docs/method/release.md`](../method/release.md) - [`docs/method/release-runbook.md`](../method/release-runbook.md) - [`docs/governance/RELEASE_POLICY.md`](../governance/RELEASE_POLICY.md) diff --git a/docs/truth-manifest.json b/docs/truth-manifest.json index 6df27e85..70a875d8 100644 --- a/docs/truth-manifest.json +++ b/docs/truth-manifest.json @@ -226,6 +226,21 @@ "status": "current", "owner": "@flyingrobots" }, + { + "path": "docs/method/release.md", + "status": "current", + "owner": "@flyingrobots" + }, + { + "path": "docs/method/release-runbook.md", + "status": "current", + "owner": "@flyingrobots" + }, + { + "path": "docs/CRATES_IO_RELEASE.md", + "status": "current", + "owner": "@flyingrobots" + }, { "path": "docs/governance/RELEASE_POLICY.md", "status": "current", diff --git a/test/ci-workflows.bats b/test/ci-workflows.bats index e394916a..1dd70352 100644 --- a/test/ci-workflows.bats +++ b/test/ci-workflows.bats @@ -278,42 +278,38 @@ load 'bats-plugins/bats-assert/load' [ "$output" -ge 3 ] } -@test "cert-shipme anchors and paginates bot comments" { - run bash -lc "grep -F '' .github/workflows/cert-shipme.yml | wc -l" +@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 - [ "$output" -eq 1 ] + [ "$output" -eq 0 ] - run bash -lc "grep -F \"github-actions[bot]\" .github/workflows/cert-shipme.yml | wc -l" + run bash -lc "grep -F 'branches: [main]' .github/workflows/cert-shipme.yml | wc -l" assert_success - [ "$output" -ge 1 ] + [ "$output" -eq 1 ] - run bash -lc "grep -F 'per_page: 100' .github/workflows/cert-shipme.yml | wc -l" + run bash -lc "grep -F 'Commit: \${GITHUB_SHA}' .github/workflows/cert-shipme.yml | wc -l" assert_success - [ "$output" -ge 1 ] + [ "$output" -eq 1 ] - run bash -lc "grep -F 'pull-requests: write' .github/workflows/cert-shipme.yml | wc -l" + run bash -lc "grep -F '' .github/workflows/cert-shipme.yml | wc -l" assert_success - [ "$output" -ge 1 ] + [ "$output" -eq 0 ] - run bash -lc "grep -F 'actions: read' .github/workflows/cert-shipme.yml | wc -l" + run bash -lc "grep -F 'Wait for HOLMES suite comment' .github/workflows/cert-shipme.yml | wc -l" assert_success - [ "$output" -ge 1 ] + [ "$output" -eq 0 ] - run bash -lc "grep -F \"github.actor != 'dependabot[bot]'\" .github/workflows/cert-shipme.yml | wc -l" + run bash -lc "grep -F 'HOLMES_SUITE_SHA:' .github/workflows/cert-shipme.yml | wc -l" assert_success - [ "$output" -eq 1 ] + [ "$output" -eq 0 ] run bash -lc "grep -F 'github.rest.issues.updateComment' .github/workflows/cert-shipme.yml | wc -l" assert_success - [ "$output" -eq 1 ] + [ "$output" -eq 0 ] run bash -lc "grep -F 'github.rest.issues.createComment' .github/workflows/cert-shipme.yml | wc -l" assert_success - [ "$output" -eq 1 ] - - run bash -lc "grep -F 'comment_id: botComment.id' .github/workflows/cert-shipme.yml | wc -l" - assert_success - [ "$output" -eq 1 ] + [ "$output" -eq 0 ] run bash -lc "grep -F 'Run HOLMES investigation' .github/workflows/cert-shipme.yml | wc -l" assert_success @@ -333,7 +329,7 @@ load 'bats-plugins/bats-assert/load' run bash -lc "grep -F \"'scripts/prepare-shipme-cert-fixture.mjs'\" .github/workflows/cert-shipme.yml | wc -l" assert_success - [ "$output" -eq 2 ] + [ "$output" -eq 1 ] run bash -lc "grep -F 'rehearse --schema test/fixtures/blade/schema-v1.graphql' .github/workflows/cert-shipme.yml | wc -l" assert_success @@ -342,38 +338,6 @@ load 'bats-plugins/bats-assert/load' run bash -lc "grep -F '.wesley-cache/holmes-report.json' .github/workflows/cert-shipme.yml | wc -l" assert_success [ "$output" -ge 1 ] - - run bash -lc "grep -F 'Wait for HOLMES suite comment' .github/workflows/cert-shipme.yml | wc -l" - assert_success - [ "$output" -eq 1 ] - - run bash -lc "grep -F 'HOLMES_SUITE_SHA:' .github/workflows/cert-shipme.yml | wc -l" - assert_success - [ "$output" -eq 1 ] - - run bash -lc "grep -F \"steps.holmes_comment_wait.outputs.ready == 'true'\" .github/workflows/cert-shipme.yml | wc -l" - assert_success - [ "$output" -eq 1 ] - - run bash -lc "grep -F 'actions/workflows/{workflow_id}/runs' .github/workflows/cert-shipme.yml | wc -l" - assert_success - [ "$output" -eq 1 ] - - run bash -lc "grep -F '2 * 60 * 60 * 1000' .github/workflows/cert-shipme.yml | wc -l" - assert_success - [ "$output" -eq 1 ] - - run bash -lc "grep -F 'const maxNoRunFound = 20' .github/workflows/cert-shipme.yml | wc -l" - assert_success - [ "$output" -eq 1 ] - - run bash -lc "grep -F 'No wesley-holmes.yml run found for' .github/workflows/cert-shipme.yml | wc -l" - assert_success - [ "$output" -eq 1 ] - - run bash -lc "grep -F 'core.setFailed(' .github/workflows/cert-shipme.yml | wc -l" - assert_success - [ "$output" -ge 1 ] } @test "retired progress workflow and README updater do not return" { @@ -461,6 +425,29 @@ load 'bats-plugins/bats-assert/load' [ "$verify_line" -lt "$finalize_line" ] } +@test "release crates workflow verifies every published crate" { + for crate in wesley-core wesley-emit-codec wesley-emit-rust wesley-emit-typescript wesley-cli; do + run bash -lc "awk '/name: Verify crates.io visibility/{in_step=1} in_step && /^ - name: Finalize GitHub Release/{exit} in_step {print}' .github/workflows/release-crates.yml | grep -F '$crate'" + assert_success + done +} + +@test "release crates workflow retries crates.io visibility checks" { + run grep -F "for attempt in \$(seq 1 30)" .github/workflows/release-crates.yml + assert_success + + run grep -F "did not become visible on crates.io in time" .github/workflows/release-crates.yml + assert_success + + run grep -F "sleep 10" .github/workflows/release-crates.yml + assert_success +} + +@test "release crates visibility assertion is scoped to visibility step" { + run bash -lc "awk '/@test \"release crates workflow verifies every published crate\"/{in_test=1} in_test && /^@test / && !/release crates workflow verifies every published crate/{exit} in_test {print}' test/ci-workflows.bats | grep -F 'Verify crates.io visibility'" + assert_success +} + @test "release crates workflow keeps release scratch files outside repository" { run bash -lc "grep -F '\${RUNNER_TEMP}/release-notes.md' .github/workflows/release-crates.yml | wc -l" assert_success diff --git a/test/docs-planning-boundary.bats b/test/docs-planning-boundary.bats index f5162a4e..13be2d6a 100644 --- a/test/docs-planning-boundary.bats +++ b/test/docs-planning-boundary.bats @@ -103,3 +103,17 @@ load 'bats-plugins/bats-assert/load' run rg -n "cargo install wesley-cli --version 0\\.1\\.0" README.md docs/GUIDE.md docs/ENTRYPOINTS.md CONTRIBUTING.md assert_failure } + +@test "HOLMES distribution favors tagged workflows over a GitHub App install path" { + run grep -F "tagged reusable GitHub Actions" docs/topics/holmes-ci.md + assert_success + + run grep -F "GitHub App the first-class HOLMES install path" docs/topics/holmes-ci.md + assert_success + + run grep -F "GitHub App is not the first-class delivery mechanism" docs/architecture/holmes-integration.md + assert_success + + run grep -F "Consumers should pin released tags, not \`main\`" docs/topics/holmes-ci.md + assert_success +} diff --git a/test/release-governance.bats b/test/release-governance.bats index 14d378ed..622a66a2 100644 --- a/test/release-governance.bats +++ b/test/release-governance.bats @@ -48,6 +48,89 @@ load 'bats-plugins/bats-assert/load' assert_success } +@test "release profile names every published Wesley crate" { + run test -f .continuum/release.yml + assert_success + + for crate in wesley-core wesley-emit-codec wesley-emit-rust wesley-emit-typescript wesley-cli; do + run grep -Eq "^[[:space:]]*-[[:space:]]*$crate$" .continuum/release.yml + assert_success + + run grep -F "name: $crate" .continuum/release.yml + assert_success + done +} + +@test "release profile names unpublished Holmes as a required version source" { + run grep -F "path: crates/wesley-holmes/Cargo.toml" .continuum/release.yml + assert_success + + run grep -F "name: wesley-holmes" .continuum/release.yml + assert_success + + run bash -lc "awk ' + /path: crates\\/wesley-holmes\\/Cargo.toml/ { in_block=1 } + in_block && /^[[:space:]]*required:[[:space:]]*true$/ { required=1 } + in_block && /^[[:space:]]*published:[[:space:]]*false$/ { published=1 } + in_block && /^[[:space:]]*-[[:space:]]*path:/ && \$0 !~ /wesley-holmes/ { in_block=0 } + END { exit !(required && published) } + ' .continuum/release.yml" + assert_success +} + +@test "release policy names unpublished Holmes as a version source" { + run grep -F "crates/wesley-holmes/Cargo.toml" docs/governance/RELEASE_POLICY.md + assert_success +} + +@test "release profile assertions are YAML spacing tolerant" { + run bash -lc "awk '/@test \"release profile names every published Wesley crate\"/{in_test=1} in_test && /^@test / && !/release profile names every published Wesley crate/{exit} in_test {print}' test/release-governance.bats | grep -F 'grep -Eq'" + assert_success + + run bash -lc "awk '/@test \"release profile names unpublished Holmes as a required version source\"/{in_test=1} in_test && /^@test / && !/release profile names unpublished Holmes as a required version source/{exit} in_test {print}' test/release-governance.bats | grep -F 'required=1'" + assert_success +} + +@test "release profile declares Rust advisory audit validation" { + run grep -F "rust_advisory_audit: cargo audit" .continuum/release.yml + assert_success +} + +@test "release profile includes public site and guide signposts" { + run grep -Eq "^[[:space:]]*-[[:space:]]*docs/site/$" .continuum/release.yml + assert_success + + run grep -Eq "^[[:space:]]*-[[:space:]]*docs/GUIDE.md$" .continuum/release.yml + assert_success +} + +@test "release doctrine lists profile user-doc signposts" { + run grep -F '`docs/site/`' docs/method/release.md + assert_success + + run grep -F '`docs/reference/`' docs/method/release.md + assert_success +} + +@test "release doctrine requires thesis scope and retrospective evidence" { + run grep -F "No planned release without a thesis." docs/method/release.md + assert_success + + run grep -F "must-ship, may-slip, and explicitly-not-included" docs/method/release.md + assert_success + + run grep -F "retrospective and fallout issues" docs/method/release.md + assert_success +} + +@test "release lifecycle uses retrospected state name" { + run rg -n "retrospectived" docs/method/release.md docs/topics/releases.md + assert_failure + + run rg -n "retrospected" docs/method/release.md docs/topics/releases.md + assert_success +} + @test "entrypoints command map lists Rust LE binary emitter" { run grep -F "wesley emit le-binary-rust --schema --out " docs/ENTRYPOINTS.md assert_success diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 02f7fd84..43ae278e 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -53,6 +53,11 @@ const PUBLISH_CRATES: &[PublishCrate] = &[ dependencies: &["wesley-core", "wesley-emit-rust", "wesley-emit-typescript"], }, ]; +const UNPUBLISHED_CARGO_VERSION_SOURCES: &[CargoVersionSource] = &[CargoVersionSource { + name: "wesley-holmes", + path: "crates/wesley-holmes", + publish: false, +}]; fn main() -> ExitCode { match run(env::args_os().skip(1).collect()) { @@ -661,12 +666,22 @@ fn check_release_tag_is_on_main(tag: &str) -> Result<(), Error> { fn check_publish_manifest_versions(version: &str) -> Result<(), Error> { let root = env::current_dir() .map_err(|source| Error::Usage(format!("failed to resolve current directory: {source}")))?; + check_publish_manifest_versions_at(&root, version) +} + +fn check_publish_manifest_versions_at(root: &Path, version: &str) -> Result<(), Error> { let publish_crate_names = PUBLISH_CRATES .iter() .map(|publish_crate| publish_crate.name) .collect::>(); let mut failures = Vec::new(); + check_root_package_version(root, version, &mut failures); + + for source in UNPUBLISHED_CARGO_VERSION_SOURCES { + check_cargo_version_source(root, source, version, &mut failures); + } + for publish_crate in PUBLISH_CRATES { let manifest_path = root.join(publish_crate.path).join("Cargo.toml"); let manifest = match read_toml_manifest(&manifest_path) { @@ -724,6 +739,90 @@ fn check_publish_manifest_versions(version: &str) -> Result<(), Error> { finish_check("release manifest versions", failures) } +fn check_cargo_version_source( + root: &Path, + source: &CargoVersionSource, + version: &str, + failures: &mut Vec, +) { + let manifest_path = root.join(source.path).join("Cargo.toml"); + let manifest = match read_toml_manifest(&manifest_path) { + Ok(manifest) => manifest, + Err(failure) => { + failures.push(failure); + return; + } + }; + + let Some(package) = manifest.get("package").and_then(toml::Value::as_table) else { + failures.push(format!("{} is missing [package]", source.path)); + return; + }; + + let name = package + .get("name") + .and_then(toml::Value::as_str) + .unwrap_or_default(); + if name != source.name { + failures.push(format!( + "{} package.name is `{name}`, expected `{}`", + source.path, source.name + )); + } + + let manifest_version = package + .get("version") + .and_then(toml::Value::as_str) + .unwrap_or_default(); + if manifest_version != version { + failures.push(format!( + "{} version is `{manifest_version}`, expected `{version}`", + source.path + )); + } + + let publish = package.get("publish").and_then(toml::Value::as_bool); + if publish != Some(source.publish) { + failures.push(format!( + "{} publish is `{}`, expected `{}`", + source.path, + publish + .map(|value| value.to_string()) + .unwrap_or_else(|| "unset".to_string()), + source.publish + )); + } +} + +fn check_root_package_version(root: &Path, version: &str, failures: &mut Vec) { + let path = root.join("package.json"); + let content = match fs::read_to_string(&path) { + Ok(content) => content, + Err(source) => { + failures.push(format!("package.json is missing or unreadable: {source}")); + return; + } + }; + + let manifest: serde_json::Value = match serde_json::from_str(&content) { + Ok(manifest) => manifest, + Err(source) => { + failures.push(format!("package.json is malformed JSON: {source}")); + return; + } + }; + + let manifest_version = manifest + .get("version") + .and_then(serde_json::Value::as_str) + .unwrap_or_default(); + if manifest_version != version { + failures.push(format!( + "package.json version is `{manifest_version}`, expected `{version}`" + )); + } +} + fn check_dependency_hygiene( publish_crate: &PublishCrate, manifest: &toml::Value, @@ -1516,16 +1615,6 @@ fn release_issue_query_specs(tag: &str, version: &str, _repo: &str) -> Vec { + assert_eq!(check, "release manifest versions"); + assert_eq!( + failures, + vec!["package.json version is `9.9.9`, expected `1.2.3`"] + ); + } + other => panic!("expected root package version failure, got {other:?}"), + } + + fs::remove_dir_all(root).expect("temp root should be removed"); + } + + #[test] + fn unpublished_holmes_version_mismatch_blocks_release_manifest_check() { + let root = env::temp_dir().join(format!( + "wesley-xtask-release-holmes-version-{}", + std::process::id() + )); + if root.exists() { + fs::remove_dir_all(&root).expect("stale temp root should be removed"); + } + fs::create_dir_all(&root).expect("temp root should be created"); + fs::write( + root.join("package.json"), + serde_json::json!({ + "name": "wesley", + "version": "1.2.3", + "private": true + }) + .to_string(), + ) + .expect("root package json should be written"); + + for publish_crate in PUBLISH_CRATES { + let crate_root = root.join(publish_crate.path); + fs::create_dir_all(crate_root.join("src")).expect("crate src should be created"); + fs::write(crate_root.join("README.md"), "# Test crate\n") + .expect("crate readme should be written"); + fs::write(crate_root.join("src/lib.rs"), "").expect("crate source should be written"); + + let mut dependency_lines = String::new(); + for dependency in publish_crate.dependencies { + dependency_lines.push_str(&format!( + "{dependency} = {{ path = \"../{dependency}\", version = \"1.2.3\" }}\n" + )); + } + fs::write( + crate_root.join("Cargo.toml"), + format!( + "[package]\nname = \"{}\"\nversion = \"1.2.3\"\nedition = \"2021\"\nreadme = \"README.md\"\n\n[dependencies]\n{}", + publish_crate.name, dependency_lines + ), + ) + .expect("crate manifest should be written"); + } + + let holmes_root = root.join("crates/wesley-holmes"); + fs::create_dir_all(holmes_root.join("src")).expect("holmes src should be created"); + fs::write( + holmes_root.join("Cargo.toml"), + "[package]\nname = \"wesley-holmes\"\nversion = \"9.9.9\"\nedition = \"2021\"\npublish = false\n", + ) + .expect("holmes manifest should be written"); + + let result = check_publish_manifest_versions_at(&root, "1.2.3"); + + match result { + Err(Error::CheckFailed { check, failures }) => { + assert_eq!(check, "release manifest versions"); + assert_eq!( + failures, + vec!["crates/wesley-holmes version is `9.9.9`, expected `1.2.3`"] + ); + } + other => panic!("expected Holmes package version failure, got {other:?}"), + } + + fs::remove_dir_all(root).expect("temp root should be removed"); + } + // --- looks_like_file_path --- #[test]