From 69e3ecff9b41513e386e5aa98b4fa052ae38431f Mon Sep 17 00:00:00 2001 From: Rand Lee Date: Wed, 26 Aug 2026 18:17:33 -0700 Subject: [PATCH 01/12] docs(phase-J): add sc-publish migration plan and hardening sprint docs. Co-authored-by: Cursor --- .gitignore | 1 + docs/WINGET_SETUP.md | 97 +++++---- .../.plan-hardening/upstream-tracking.md | 8 + docs/plans/phase-J/README.md | 190 ++++++++++++++++++ .../plans/phase-J/j1-vendor-sc-publish-kit.md | 100 +++++++++ .../phase-J/j2-upstream-blockers-and-docs.md | 71 +++++++ docs/plans/phase-J/j3-release-rehearsal.md | 59 ++++++ docs/plans/phase-J/j4-production-cutover.md | 66 ++++++ docs/plans/project-plan.md | 25 +++ release/install.json | 134 ++++++++++++ scripts/sync-sc-publish.sh | 36 ++++ 11 files changed, 752 insertions(+), 35 deletions(-) create mode 100644 docs/plans/phase-J/.plan-hardening/upstream-tracking.md create mode 100644 docs/plans/phase-J/README.md create mode 100644 docs/plans/phase-J/j1-vendor-sc-publish-kit.md create mode 100644 docs/plans/phase-J/j2-upstream-blockers-and-docs.md create mode 100644 docs/plans/phase-J/j3-release-rehearsal.md create mode 100644 docs/plans/phase-J/j4-production-cutover.md create mode 100644 release/install.json create mode 100755 scripts/sync-sc-publish.sh diff --git a/.gitignore b/.gitignore index d6e85dcf..3b30380d 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ Cargo.lock .env .sc/ .sc-compose/ +.sc-publish-venv/ tests/e2e/node_modules/ tests/e2e/test-results/ tests/e2e/playwright-report/ diff --git a/docs/WINGET_SETUP.md b/docs/WINGET_SETUP.md index 31eecb59..c22047f2 100644 --- a/docs/WINGET_SETUP.md +++ b/docs/WINGET_SETUP.md @@ -1,49 +1,76 @@ -# Windows Package Manager (`winget`) Setup +# Windows Package Manager (`winget`) — sc-publish profile -This document explains the retained `winget` path for `wyvern` published from the -`wyvern` repo. +Wyvern publishes to `winget` via the vendored **sc-publish** kit. This replaces +the legacy inline `release.yml` job and fixes the v0.5.0 failure mode (missing +fork token + wrong asset name). -## Package Identity +## Package identity -- Package identifier: `randlee.wyvern` -- Installed binary: `wyvern` -- Release source repo: `https://github.com/randlee/wyvern` +| Field | Value | +|-------|-------| +| Identifier | `randlee.wyvern` | +| Installed binary | `wyvern` (from `bin/wyvern.exe` in release archive) | +| Source repo | `https://github.com/randlee/wyvern` | -## Release Model +Declared in `release/install.json` → rendered `release/publish-artifacts.toml` +`[channels.winget]`. -- The first `winget` release requires a one-time manual manifest submission to - `microsoft/winget-pkgs`. -- After that bootstrap submission, later releases are automated by the release - workflow via `vedantmgoyal2009/winget-releaser@v2`. -- No `winget`-specific repository secret is required; the default - `GITHUB_TOKEN` is sufficient for the workflow step. +## Release model (kit) -## Installer Source +1. **Root release** (`release.yml` on `main`) builds and uploads GitHub Release + assets, including Windows ZIP: + `wyvern__x86_64-pc-windows-msvc.zip` +2. **Post-release leg** — dispatch `winget-publish.yml` with input `tag=vX.Y.Z`. +3. Workflow reads manifest config, verifies release assets exist, then: + - Probes `microsoft/winget-pkgs` for existing manifest or open PR (fail closed) + - Submits via pinned `vedantmgoyal2009/winget-releaser@v2` if absent +4. **Retry:** Re-dispatch `winget-publish.yml` for the same tag; detect-and-skip + prevents duplicate submissions. -The workflow submits the Windows ZIP asset from the GitHub Release: +## Required secret -- `wyvern-windows.zip` +| Secret | Purpose | +|--------|---------| +| **`WINGET_GITHUB_TOKEN`** | PAT that can fork `microsoft/winget-pkgs` and open submission PRs | -The `winget` submission uses the ZIP asset URL and SHA256 from the release. -No extra repository secret is required beyond the default `GITHUB_TOKEN` (same -as atm-core). +The repository **`GITHUB_TOKEN` is not sufficient** for this external channel. +Preflight fails closed if `WINGET_GITHUB_TOKEN` is missing or not live. -## Review Lag +See also: [docs/RELEASE_SECRETS.md](RELEASE_SECRETS.md), +`release/publish-channel-contracts.toml` `[channels.winget]`. -Microsoft review normally introduces a 1-2 day lag between submission and -public `winget install` visibility. Release verification for Wyvern therefore -checks submission success, not same-day installability. +## First-release bootstrap (one-time) -## First Release Bootstrap +Before the automated leg can succeed, `randlee.wyvern` must exist in +`microsoft/winget-pkgs`: -For the initial submission: +1. Ship a GitHub Release with the Windows ZIP asset (kit archive name above). +2. Prepare the initial three-file manifest set (version, installer, locale). +3. Submit manually to `microsoft/winget-pkgs` (or via maintainer fork PR). +4. After merge, use `winget-publish.yml` for all subsequent versions. -1. Build and publish the GitHub Release as usual. -2. Update the template manifest under `.winget/`. -3. Prepare the initial three-file manifest set for `microsoft/winget-pkgs`: - - version manifest - - installer manifest - - locale/default-locale manifest -4. Submit that initial manifest set to `microsoft/winget-pkgs`. -5. After that first package exists, keep using the automated workflow step for - later releases. +## Verification + +Release verification checks **submission success**, not same-day +`winget install` visibility — Microsoft review typically adds 1–2 days lag. + +```bash +# After dispatch +gh run list --workflow winget-publish.yml --limit 1 +# Probe (optional) +gh api "repos/microsoft/winget-pkgs/contents/manifests/r/randlee/wyvern/" +``` + +## Operator dispatch + +```bash +gh workflow run winget-publish.yml -f tag=vX.Y.Z --ref main +gh run watch --exit-status +``` + +Cursor/ATM: follow `.claude/agents/winget-publisher.md` (inline in Cursor). + +## Related + +- Phase J plan: [docs/plans/phase-J/README.md](plans/phase-J/README.md) +- Kit workflow: `.github/workflows/winget-publish.yml` (vendored — do not edit) diff --git a/docs/plans/phase-J/.plan-hardening/upstream-tracking.md b/docs/plans/phase-J/.plan-hardening/upstream-tracking.md new file mode 100644 index 00000000..3d712794 --- /dev/null +++ b/docs/plans/phase-J/.plan-hardening/upstream-tracking.md @@ -0,0 +1,8 @@ +# CR-001 / CR-002 upstream tracking (j.2) + +| Blocker | Status | Link | Waiver signed | +|---------|--------|------|---------------| +| CR-001 Linux webview deps in kit jobs | pending | | | +| CR-002 Homebrew sc-compose renderer | pending | | | + +Updated in sprint j.2 closeout. diff --git a/docs/plans/phase-J/README.md b/docs/plans/phase-J/README.md new file mode 100644 index 00000000..ae70514b --- /dev/null +++ b/docs/plans/phase-J/README.md @@ -0,0 +1,190 @@ +# Phase J — sc-publish migration (`integrate/phase-J`) + +Phase J replaces Wyvern's bespoke release/publish stack with the shared +**[`sc-publish`](https://github.com/randlee/sc-publish)** kit so Rust repos +(wyvern, atm-core, …) use the same agents, workflows, manifest contract, and +per-channel retry semantics. + +Implementation PRs target **`integrate/phase-J`**. Sprint docs are **sole +authority** for deliverables, acceptance criteria, and required validation. + +**Planning worktree:** `plan/phase-J-sc-publish` (from `develop`). + +**Prerequisite:** Phase I merged to `develop` (or current release line on +`main` @ v0.5.0+). Phase J is **tooling only** — no product dialog/host +changes unless a publish rehearsal exposes a packaging bug. + +**Baseline branch:** Worktrees branch from `integrate/phase-J` (or `develop` +after planning lands). + +--- + +## Problem + +Wyvern today ships with a **one-off** publish surface: + +| Area | Today (develop/main) | Target (sc-publish) | +|------|----------------------|---------------------| +| Workflows | Tag-push `release.yml`, inline Homebrew/winget jobs | `release-candidate` → preflight → `workflow_dispatch` release + per-channel retries | +| Scripts | `scripts/release_*.py` (removed on plan branch) | Vendored `.github/scripts/` (byte-for-byte from kit) | +| Manifest | Hand-edited `release/publish-artifacts.toml` | Rendered from caller-owned `release/install.json` | +| Agents | Single bespoke `publisher.md` | Kit publisher + channel playbooks (ATM + Cursor inline) | +| Winget | Inline step; `GITHUB_TOKEN`; asset `wyvern-windows.zip` | `winget-publish.yml`; **`WINGET_GITHUB_TOKEN`**; manifest asset pattern | +| Retry | `release-retry-distribution.yml` | `crates-publish.yml`, `homebrew-publish.yml`, `winget-publish.yml`, … | + +This drift blocks **consistent publish ops** across Rust repos and is why v0.5.0 +winget failed (no bootstrap + wrong token model). + +--- + +## Phase goal + +1. Vendor sc-publish into wyvern via `release/install.json` + `scripts/sync-sc-publish.sh`. +2. Retire bespoke publish workflows/agents without forking kit YAML. +3. Adopt the kit **release state machine** (candidate tag → `main` → dispatch). +4. Cut the **first kit-managed production release** with all declared channels + verified (crates.io, GitHub Release assets, Homebrew, winget post-dispatch). + +--- + +## sc-publish architecture (consumer view) + +Source of truth: sibling repo `../sc-publish` (`plugins/sc-publish`). + +```bash +# From wyvern repo root (any worktree): +./scripts/sync-sc-publish.sh +# → pulls ../sc-publish @ main, bootstraps sc-compose 1.5.0, runs install.py +# → copies agents/workflows/scripts verbatim; renders two manifests only +``` + +| Asset | Role | +|-------|------| +| `release/install.json` | **Only** hand-maintained publish contract (crates, targets, channels) | +| `release/publish-artifacts.toml` | Rendered — repo-specific artifacts & destinations | +| `release/publish-channel-contracts.toml` | Vendored unchanged — secret names, liveness checks | +| `.github/workflows/release*.yml` | Root + preflight + candidate | +| `.github/workflows/*-publish.yml` | Post-release per-channel retries | +| `.claude/agents/*-publisher.md` | ATM channel workers + coordinator | +| `.cursor/agents/publisher.md` | Cursor inline profile | + +**Kit rule:** Do not hand-edit copied kit files. Fix upstream sc-publish or +change `install.json`, then re-sync. + +Normative kit requirements: +[`README.sc-publish.md`](../../../README.sc-publish.md), +[`.claude/skills/publishing/ref/publish-kit-requirements.md`](../../../.claude/skills/publishing/ref/publish-kit-requirements.md). + +--- + +## Sprint map + +Sprint docs are **sole authority** for deliverables, acceptance criteria, and +required validation. This README is the phase index only. + +| Sprint | Ships | Doc | +|--------|-------|-----| +| **j.1** | Vendor kit, `install.json`, sync script | [j1-vendor-sc-publish-kit.md](j1-vendor-sc-publish-kit.md) | +| **j.2** | Upstream blockers, secrets, winget/docs | [j2-upstream-blockers-and-docs.md](j2-upstream-blockers-and-docs.md) | +| **j.3** | Release rehearsal on kit state machine | [j3-release-rehearsal.md](j3-release-rehearsal.md) | +| **j.4** | Production cutover; retire tag-push | [j4-production-cutover.md](j4-production-cutover.md) | + +**Merge order → `integrate/phase-J`:** j.1 → j.2 → j.3 → j.4. + +--- + +## Winget (why sc-publish fixes it) + +v0.5.0 winget failed because: + +1. Inline job used repository `GITHUB_TOKEN` — cannot fork `microsoft/winget-pkgs`. +2. Asset regex targeted `wyvern-windows.zip`; kit archives rename to + `wyvern__x86_64-pc-windows-msvc.zip`. +3. No idempotent post-release leg — retries were bundled with crates/Homebrew. + +**Kit fix:** + +- `winget-publish.yml` — standalone `workflow_dispatch` by tag. +- `WINGET_GITHUB_TOKEN` — PAT with rights to fork/submit to `winget-pkgs` + (declared in `publish-channel-contracts.toml`). +- Manifest-driven `installers-regex` from `release_artifacts.py channel-config`. +- Detect-and-skip: probe existing manifest / open PR before submitting (fail closed). +- One-time bootstrap to `microsoft/winget-pkgs` still required for `randlee.wyvern`; + after bootstrap, automated leg owns subsequent versions. + +See [docs/WINGET_SETUP.md](../../../WINGET_SETUP.md) (updated in j.2). + +--- + +## Critical review findings (Grok, planning) + +Planning review **rejects** “simply copy/replace + re-publish on next tag.” +Findings below are **in scope** for Phase J sprints. + +| ID | Finding | Plan response | Sprint | +|----|---------|---------------|--------| +| CR-001 | Linux webview deps missing from kit release/preflight/crates jobs | Upstream sc-publish hook **or** documented waiver + CI-only gate until landed | j.2 | +| CR-002 | `renderer_archive_path = bin/wyvern` invalid for Homebrew formula render | Upstream kit must bootstrap sc-compose on runner; do not use product binary as renderer | j.2 | +| CR-003 | Homebrew UI path may flatten `share/wyvern/ui` | Fix `install.json` `homebrew_destination_components` → `["share","wyvern","ui"]` | j.2 | +| CR-004 | Tag-push trigger vs kit `workflow_dispatch` | j.4 retires tag-push; until then keep old workflow on `develop` if emergency ship needed | j.4 | +| CR-005 | Archive rename breaks README/tap/docs | Update consumer docs in j.2; call out breaking URL change in j.4 release notes | j.2, j.4 | +| CR-006 | `WINGET_GITHUB_TOKEN` required | Provision secret; update `docs/RELEASE_SECRETS.md` | j.2 | +| CR-007 | Wyvern-only gates stay in `ci.yml` | Explicit boundary — kit preflight does not replace ui-sync/boundaries | j.1 | +| CR-008 | `scripts/validate_release.py` half-migrated | Delete or rewrite against kit CLI; not wired into kit workflows | j.2 | +| CR-009 | `sync-sc-publish.sh` mutates sibling repo checkout | Document; optional pin to SHA instead of `main` | j.1 | + +**Hardening gate:** Each sprint doc passes plan-scope review and critical-plan +review (Grok) before implementation merge. + +--- + +## Phase acceptance criteria + +1. j.1–j.4 sprint acceptance criteria pass on `integrate/phase-J` (see sprint docs). +2. Plan hardening: `plan-scope-reviewer` + `critical-plan-reviewer` PASS; `quality-mgr` plan QA PASS. +3. `./scripts/sync-sc-publish.sh` dry-run exit **0** on final branch. +4. First kit-managed production release verified per j.4. + +--- + +## Boundaries (Wyvern-owned, not kit) + +| Stays in wyvern | Reason | +|-----------------|--------| +| `.github/workflows/ci.yml` | Product gates: boundaries, ui-sync, share-sync, audit/deny | +| `scripts/check-*.sh`, `check-boundaries.py` | Product integrity | +| `ui/`, crates, `third_party/wayland-scanner` | Product | +| `release/install.json` | Consumer contract | +| `scripts/sync-sc-publish.sh` | Re-vendor entrypoint | + +Do **not** re-implement product gates inside kit YAML forks. + +--- + +## Phase integration smoke (non-normative) + +After j.4 on `integrate/phase-J`: + +```bash +# Manifest +python3 .github/scripts/release_artifacts.py validate-manifest \ + --manifest release/publish-artifacts.toml --workspace-toml Cargo.toml + +# Kit drift +./scripts/sync-sc-publish.sh # ends with dry-run exit 0 + +# Channels (after production tag vX.Y.Z) +curl -fsS -A wyvern-check "https://crates.io/api/v1/crates/wyvern-cli/X.Y.Z" +gh release view vX.Y.Z --json assets +curl -fsS "https://raw.githubusercontent.com/randlee/homebrew-tap/main/Formula/wyvern.rb" | rg version +gh workflow run winget-publish.yml -f tag=vX.Y.Z # when token provisioned +``` + +--- + +## Related + +- [`release/install.json`](../../../release/install.json) — Wyvern publish contract +- [`scripts/sync-sc-publish.sh`](../../../scripts/sync-sc-publish.sh) — vendor entrypoint +- [docs/RELEASE_SECRETS.md](../../../docs/RELEASE_SECRETS.md) — updated j.2 +- [docs/WINGET_SETUP.md](../../../WINGET_SETUP.md) — updated j.2 diff --git a/docs/plans/phase-J/j1-vendor-sc-publish-kit.md b/docs/plans/phase-J/j1-vendor-sc-publish-kit.md new file mode 100644 index 00000000..43553167 --- /dev/null +++ b/docs/plans/phase-J/j1-vendor-sc-publish-kit.md @@ -0,0 +1,100 @@ +--- +id: j.1 +title: Vendor sc-publish kit +status: planning +branch: feature/phase-J-j1-vendor-sc-publish +worktree: ../wyvern-worktrees/feature/phase-J-j1-vendor-sc-publish +target: integrate/phase-J +--- + +# Sprint j.1 — Vendor sc-publish kit + +## Goal + +Vendor the shared [`sc-publish`](https://github.com/randlee/sc-publish) kit into +wyvern: workflows, scripts, agents, rendered manifests, and a repeatable sync +entrypoint. Remove legacy bespoke publish files. + +## Hard dependencies + +- `develop` @ v0.5.0+ (current release line) +- Sibling repo `../sc-publish` @ `main` (see `scripts/sync-sc-publish.sh`) + +## Deliverables + +| Path | Purpose | +|------|---------| +| `release/install.json` | Caller-owned publish contract (crates, targets, channels) | +| `scripts/sync-sc-publish.sh` | Pull `../sc-publish`, bootstrap sc-compose, run `install.py` | +| `.github/workflows/release.yml` | Kit root release (vendored) | +| `.github/workflows/release-preflight.yml` | Kit preflight (vendored) | +| `.github/workflows/release-candidate.yml` | Kit candidate tag (vendored) | +| `.github/workflows/crates-publish.yml` | Per-channel crates retry (vendored) | +| `.github/workflows/homebrew-publish.yml` | Per-channel Homebrew retry (vendored) | +| `.github/workflows/winget-publish.yml` | Per-channel winget retry (vendored) | +| `.github/scripts/release_artifacts.py` | Manifest CLI (vendored) | +| `.github/scripts/release_gate.sh` | Release gate (vendored) | +| `.claude/agents/publisher.md` + channel publishers | ATM publish profile (vendored) | +| `.cursor/agents/publisher.md` + publish skill/command | Cursor inline profile (vendored) | +| `release/publish-artifacts.toml` | Rendered from `install.json` | +| `release/publish-channel-contracts.toml` | Vendored channel protocol | +| `README.sc-publish.md` | Kit README (does not replace Wyvern `README.md`) | +| `.gitignore` | Entry `.sc-publish-venv/` | + +## Paths to delete + +| Path | Reason | +|------|--------| +| `scripts/release_artifacts.py` | Replaced by `.github/scripts/` | +| `scripts/release_gate.sh` | Replaced by `.github/scripts/` | +| `.github/workflows/release-retry-distribution.yml` | Replaced by per-channel workflows | + +## Consumer contract (normative sample) + +`release/install.json` minimum shape (Wyvern values): + +```json +{ + "schema_version": 1, + "project": { + "name": "wyvern", + "archive_prefix": "wyvern", + "readme_dependency_crate": "wyvern-schema", + "renderer_archive_path": "bin/wyvern", + "workspace_toml": "Cargo.toml", + "rust_toolchain": "stable" + }, + "channels": { + "homebrew": { "tap_repository": "randlee/homebrew-tap", "...": "..." }, + "winget": { "identifier": "randlee.wyvern", "installer_target": "x86_64-pc-windows-msvc" } + } +} +``` + +Full file is authoritative at `release/install.json`. + +## Acceptance criteria + +1. `./scripts/sync-sc-publish.sh` resolves `../sc-publish` via git common dir, fetches `main`, installs with `--input release/install.json`, ends with dry-run exit **0**. +2. `release/install.json` declares five publishable crates (orders 1–5), `wyvern-mcp` unpublished, four release targets, binaries `wyvern` + `wyvern-viewer`, bundled `ui/`, channels **homebrew** + **winget** only. +3. Every deliverable path in the table above exists after sync (kit byte-for-byte copies). +4. Every path in **Paths to delete** is absent from the branch. +5. `python3 .github/scripts/release_artifacts.py validate-manifest --manifest release/publish-artifacts.toml --workspace-toml Cargo.toml` exits **0**. +6. `.github/workflows/ci.yml` is unchanged except documented path references (product gates stay in CI, not kit preflight). + +## Non-closure (explicit) + +- **j.1 does not** resolve upstream Linux deps (CR-001), Homebrew renderer (CR-002), or provision secrets — **j.2**. +- **j.1 does not** cut a release or retire tag-push workflow — **j.4**. +- Hand-editing vendored kit YAML is **forbidden**; fixes go to `install.json` or upstream sc-publish. + +## Required validation + +```bash +./scripts/sync-sc-publish.sh +python3 .github/scripts/release_artifacts.py validate-manifest \ + --manifest release/publish-artifacts.toml --workspace-toml Cargo.toml +! git grep -n 'scripts/release_artifacts.py' -- ':!docs/' ':!.github/scripts/tests/' +test ! -f scripts/release_gate.sh +test ! -f .github/workflows/release-retry-distribution.yml +``` diff --git a/docs/plans/phase-J/j2-upstream-blockers-and-docs.md b/docs/plans/phase-J/j2-upstream-blockers-and-docs.md new file mode 100644 index 00000000..37c720fc --- /dev/null +++ b/docs/plans/phase-J/j2-upstream-blockers-and-docs.md @@ -0,0 +1,71 @@ +--- +id: j.2 +title: Upstream blockers, secrets, and consumer docs +status: planning +branch: feature/phase-J-j2-upstream-docs +worktree: ../wyvern-worktrees/feature/phase-J-j2-upstream-docs +target: integrate/phase-J +depends_on: j.1 +--- + +# Sprint j.2 — Upstream blockers, secrets, and consumer docs + +## Goal + +Close or **explicitly waive** Grok blockers before j.3 rehearsal. Update Wyvern +consumer docs and secret inventory for sc-publish (especially winget). Fix +`install.json` manifest issues without forking kit YAML. + +## Hard dependencies + +- j.1 merged to `integrate/phase-J` + +## Deliverables + +| Path | Purpose | +|------|---------| +| `release/install.json` | Homebrew UI path fix (`homebrew_destination_components`) | +| `docs/RELEASE_SECRETS.md` | Kit secret names incl. `WINGET_GITHUB_TOKEN` | +| `docs/WINGET_SETUP.md` | sc-publish winget leg + bootstrap + token model | +| `README.md` | Kit archive naming (`wyvern__.*`, `bin/` layout) | +| `docs/plans/phase-J/.plan-hardening/upstream-tracking.md` | Links to sc-publish PRs/issues for CR-001/CR-002 | +| GitHub repo secrets (owner) | `WINGET_GITHUB_TOKEN` provisioned or documented gap | + +## Paths to delete + +| Path | Reason | +|------|--------| +| `scripts/validate_release.py` | Or rewrite to kit CLI only; must not reference removed subcommands | + +## Upstream outcomes (choose one per blocker — document in closeout) + +| Blocker | Resolved | Waived | +|---------|----------|--------| +| CR-001 Linux webview deps | sc-publish PR merged; kit jobs install apt packages | j.3/j.4 blocked; emergency tag-push retained on `develop` until merged | +| CR-002 Homebrew renderer | sc-publish PR: bootstrap sc-compose on runner | `channels.homebrew` removed from `install.json`; tap manual until upstream | + +## Acceptance criteria + +1. `release/install.json` sets `homebrew_destination_components` to `["share","wyvern","ui"]` for bundled UI; re-sync dry-run exit **0**. +2. `docs/RELEASE_SECRETS.md` documents `CARGO_REGISTRY_TOKEN`, `HOMEBREW_TAP_TOKEN`, **`WINGET_GITHUB_TOKEN`** with purpose (no secret values). +3. `docs/WINGET_SETUP.md` documents: `winget-publish.yml` dispatch, `WINGET_GITHUB_TOKEN`, asset pattern `wyvern_*_x86_64-pc-windows-msvc.zip`, one-time bootstrap, Microsoft review lag. +4. `README.md` artifact table matches kit archive names and `bin/` layout. +5. `scripts/validate_release.py` deleted **or** rewritten to use `.github/scripts/release_artifacts.py` subcommands that exist in kit @ synced version. +6. `upstream-tracking.md` records CR-001/CR-002 disposition (resolved PR link **or** signed waiver text). +7. `gh secret list` shows `WINGET_GITHUB_TOKEN` **or** sprint closeout documents owner provisioning as gate before j.3. + +## Non-closure (explicit) + +- **j.2 does not** execute release rehearsal — **j.3**. +- **j.2 does not** submit winget bootstrap PR to `microsoft/winget-pkgs` unless owner chooses to do so as prep; j.3 AC covers submission attempt. +- If CR-001 **and** CR-002 are both waived, j.3 must not merge until waiver is signed in closeout. + +## Required validation + +```bash +./scripts/sync-sc-publish.sh +python3 .github/scripts/release_artifacts.py validate-manifest \ + --manifest release/publish-artifacts.toml --workspace-toml Cargo.toml +rg 'WINGET_GITHUB_TOKEN' docs/RELEASE_SECRETS.md docs/WINGET_SETUP.md +rg 'wyvern_.*_x86_64-pc-windows-msvc' docs/WINGET_SETUP.md README.md +``` diff --git a/docs/plans/phase-J/j3-release-rehearsal.md b/docs/plans/phase-J/j3-release-rehearsal.md new file mode 100644 index 00000000..657b3dba --- /dev/null +++ b/docs/plans/phase-J/j3-release-rehearsal.md @@ -0,0 +1,59 @@ +--- +id: j.3 +title: Kit release rehearsal +status: planning +branch: feature/phase-J-j3-rehearsal +worktree: ../wyvern-worktrees/plan/phase-J-j3-rehearsal +target: integrate/phase-J +depends_on: j.2 +--- + +# Sprint j.3 — Kit release rehearsal + +## Goal + +Execute one full sc-publish release drill on a rehearsal version without +surprising production download consumers. Prove candidate → `main` → preflight → +release dispatch → assets → post-release channel legs. + +## Hard dependencies + +- j.2 closeout: CR-001/CR-002 resolved **or** signed waiver +- `WINGET_GITHUB_TOKEN` provisioned (j.2 AC #7) +- GitHub environment `crates-io` exists + +## Deliverables + +| Path | Purpose | +|------|---------| +| `docs/plans/phase-J/.plan-hardening/rehearsal-record.md` | Run IDs, tag, channel outcomes, waiver notes | + +No product code changes expected unless rehearsal exposes packaging bug (fix in-place, document in record). + +## Acceptance criteria + +1. `release-candidate.yml` dispatched; tag `release-candidate-vX.Y.Z` on `origin/develop`. +2. Branch `release/vX.Y.Z` merged to **`main`** with version lockstep. +3. `release-preflight.yml` green on exact `main` SHA. +4. `release.yml` `target=production` creates `vX.Y.Z` and GitHub Release with kit asset names. +5. Each archive contains `bin/wyvern`, `bin/wyvern-viewer`, and `share/wyvern/ui/{message,input,markdown,question,chrome}/index.html`. +6. crates.io publish leg succeeds or detect-and-skips already-published crates. +7. `homebrew-publish.yml` succeeds **or** waived per j.2 closeout (failure without waiver = sprint fail). +8. `winget-publish.yml` reaches submission (PR opened or confirmed skip probe); missing token = sprint fail. +9. Re-dispatch one post-release leg; detect-and-skip prevents duplicate publish. +10. `rehearsal-record.md` lists all workflow run URLs and go/no-go for j.4. + +## Non-closure (explicit) + +- Microsoft winget **install visibility** is not required same-day; submission success counts (per `WINGET_SETUP.md`). +- Rehearsal version may be pre-release bump; need not be the final marketing version. + +## Required validation + +Evidence in `rehearsal-record.md`: + +```bash +gh release view vX.Y.Z --json assets +curl -fsS -A wyvern-check "https://crates.io/api/v1/crates/wyvern-cli/X.Y.Z" +gh run list --workflow winget-publish.yml --limit 3 +``` diff --git a/docs/plans/phase-J/j4-production-cutover.md b/docs/plans/phase-J/j4-production-cutover.md new file mode 100644 index 00000000..b2eea660 --- /dev/null +++ b/docs/plans/phase-J/j4-production-cutover.md @@ -0,0 +1,66 @@ +--- +id: j.4 +title: Production cutover +status: planning +branch: feature/phase-J-j4-cutover +worktree: ../wyvern-worktrees/feature/phase-J-j4-cutover +target: integrate/phase-J +depends_on: j.3 +--- + +# Sprint j.4 — Production cutover + +## Goal + +Merge `integrate/phase-J` → `develop`. Cut first **production** release fully on +sc-publish. Retire tag-push release workflow. + +## Hard dependencies + +- j.3 `rehearsal-record.md` go/no-go = **go** +- No open CR-001/CR-002 waivers unless explicitly re-signed + +## Deliverables + +| Path | Purpose | +|------|---------| +| `.github/workflows/release.yml` (kit) | Sole release entry; tag-push trigger removed | +| `release/release-notes.md` | Breaking archive URL change called out | +| `docs/plans/phase-J/.plan-hardening/cutover-record.md` | Production run IDs, channel verification | + +## Paths to delete + +| Path | Reason | +|------|--------| +| Tag-push `on.push.tags: v*` trigger | Replaced by kit `workflow_dispatch` on `main` | + +Implementation: remove legacy trigger from any remaining bespoke release workflow file; kit `release.yml` must be the only root release workflow. + +## Breaking change (normative) + +| Old (v0.5.0) | New (kit) | +|--------------|-----------| +| `wyvern-macos-aarch64.tar.gz` | `wyvern_X.Y.Z_aarch64-apple-darwin.tar.gz` | +| `wyvern-windows.zip` | `wyvern_X.Y.Z_x86_64-pc-windows-msvc.zip` | +| Binaries at archive root | `bin/wyvern`, `bin/wyvern-viewer` | + +## Acceptance criteria + +1. `integrate/phase-J` merged to `develop` after phase-end QA PASS. +2. No workflow on `develop` triggers release on tag push alone. +3. Production `vX.Y.Z` shipped via kit state machine (same steps as j.3). +4. Release notes document archive rename and winget review lag. +5. Verified: crates.io, GitHub Release (four targets), Homebrew tap bump (unless j.2 Homebrew waiver still active — then **fail**), winget submission success. +6. `main` → `develop` back-merge PR opened post-release (publisher policy). + +## Non-closure (explicit) + +- **j.4 does not** merge back-merge PR — publisher/owner after CI green. + +## Required validation + +```bash +! gh workflow view release.yml --yaml | rg 'push:\s*$' -A1 | rg 'tags:' +gh release view vX.Y.Z --json assets +brew info wyvern # tap version matches X.Y.Z when Homebrew channel active +``` diff --git a/docs/plans/project-plan.md b/docs/plans/project-plan.md index d8ad0146..57d2992f 100644 --- a/docs/plans/project-plan.md +++ b/docs/plans/project-plan.md @@ -19,6 +19,7 @@ A sprint is a single testable deliverable that fits within one AI context window | `integrate/phase-E` | Phase E — Persistent & MCP | `docs/plans/phase-E/` | | `integrate/phase-H` | Phase H — XHTML reporting & review | `docs/plans/phase-H/` | | `integrate/phase-I` | Phase I — Wizard native path picker | `docs/plans/phase-I/` | +| `integrate/phase-J` | Phase J — sc-publish migration | `docs/plans/phase-J/` | Phase A sprint PRs target `integrate/phase-A`. Sprint authority: `docs/plans/phase-A/` (sprints **a.1–a.7**). @@ -298,6 +299,29 @@ See [docs/plans/phase-I/README.md](phase-I/README.md). --- +## Phase J — sc-publish migration + +**Phase goal:** Replace bespoke publish workflows/agents with the shared +**[`sc-publish`](https://github.com/randlee/sc-publish)** kit for consistency +across Rust repos — manifest-driven releases, per-channel retries, and fixed +winget/Homebrew semantics. + +**Prerequisite:** Current release line on `main` (v0.5.0+). Tooling-only phase. + +Phase J sprint PRs target `integrate/phase-J`. Sprint authority: +`docs/plans/phase-J/` (sprints **j.1–j.4**, sequential with j.3 rehearsal gate). + +| Sprint | Deliverable | Doc | +|--------|-------------|-----| +| j.1 | Vendor kit + `install.json` + sync script | [j1-vendor-sc-publish-kit.md](phase-J/j1-vendor-sc-publish-kit.md) | +| j.2 | Upstream blockers, secrets, winget/docs | [j2-upstream-blockers-and-docs.md](phase-J/j2-upstream-blockers-and-docs.md) | +| j.3 | Full release rehearsal (kit state machine) | [j3-release-rehearsal.md](phase-J/j3-release-rehearsal.md) | +| j.4 | Production cutover; retire tag-push release | [j4-production-cutover.md](phase-J/j4-production-cutover.md) | + +See [docs/plans/phase-J/README.md](phase-J/README.md). + +--- + ## Phase Summary | Phase | Sprints | Ships | @@ -310,6 +334,7 @@ See [docs/plans/phase-I/README.md](phase-I/README.md). | Phase E — Interactive & MCP | 4 | Agent-driveable status viewer + MCP | | Phase H — XHTML reporting & review | 5 | Single/array XHTML panels + `--review` finish JSON | | Phase I — Wizard native path picker | 1 | Wizard in-page native pickers + `path-picker` example (closes #99) | +| Phase J — sc-publish migration | 4 | Kit vendored publish; winget/Homebrew/crates retry legs; tag-push retired | ## Dependency Map diff --git a/release/install.json b/release/install.json new file mode 100644 index 00000000..45b3eb66 --- /dev/null +++ b/release/install.json @@ -0,0 +1,134 @@ +{ + "schema_version": 1, + "project": { + "name": "wyvern", + "archive_prefix": "wyvern", + "description": "Native webview dialogs for CLI agents (JSON in / JSON out)", + "homepage": "https://github.com/randlee/wyvern", + "license": "MIT", + "readme_dependency_crate": "wyvern-schema", + "renderer_archive_path": "bin/wyvern", + "workspace_toml": "Cargo.toml", + "rust_toolchain": "stable" + }, + "release_targets": [ + { + "target": "aarch64-apple-darwin", + "os": "macos-latest", + "archive": "tar.gz" + }, + { + "target": "x86_64-apple-darwin", + "os": "macos-latest", + "archive": "tar.gz" + }, + { + "target": "x86_64-pc-windows-msvc", + "os": "windows-latest", + "archive": "zip" + }, + { + "target": "x86_64-unknown-linux-gnu", + "os": "ubuntu-latest", + "archive": "tar.gz" + } + ], + "crates": [ + { + "artifact": "wyvern-schema", + "package": "wyvern-schema", + "cargo_toml": "crates/wyvern-schema/Cargo.toml", + "publish": true, + "publish_order": 1, + "wait_after_publish_seconds": 30 + }, + { + "artifact": "wyvern-wizard", + "package": "wyvern-wizard", + "cargo_toml": "crates/wyvern-wizard/Cargo.toml", + "publish": true, + "publish_order": 2, + "wait_after_publish_seconds": 30 + }, + { + "artifact": "wyvern-host", + "package": "wyvern-host", + "cargo_toml": "crates/wyvern-host/Cargo.toml", + "publish": true, + "publish_order": 3, + "wait_after_publish_seconds": 30 + }, + { + "artifact": "wyvern-viewer", + "package": "wyvern-viewer", + "cargo_toml": "crates/wyvern-viewer/Cargo.toml", + "publish": true, + "publish_order": 4, + "wait_after_publish_seconds": 30 + }, + { + "artifact": "wyvern", + "package": "wyvern-cli", + "cargo_toml": "crates/wyvern/Cargo.toml", + "publish": true, + "publish_order": 5, + "wait_after_publish_seconds": 0 + }, + { + "artifact": "wyvern-mcp", + "package": "wyvern-mcp", + "cargo_toml": "crates/wyvern-mcp/Cargo.toml", + "publish": false, + "publish_order": 0, + "wait_after_publish_seconds": 0 + } + ], + "release_binaries": [ + { + "name": "wyvern", + "bundled_paths": [ + { + "source": "ui", + "destination": "share/wyvern/ui", + "homebrew_destination_components": ["share", "wyvern", "ui"] + } + ] + }, + { + "name": "wyvern-viewer" + } + ], + "python_packages": [], + "python_distributions": [], + "channels": { + "homebrew": { + "workflow": "homebrew-publish.yml", + "dispatch_inputs": {}, + "tap_repository": "randlee/homebrew-tap", + "renderer_target": "x86_64-unknown-linux-gnu", + "formulas": [ + { + "path": "Formula/wyvern.rb", + "template": "release/homebrew/formula.rb.j2", + "class": "Wyvern", + "binaries": ["wyvern", "wyvern-viewer"], + "test_binary": "wyvern", + "test_command": "--help", + "test_output": "Usage: wyvern", + "release_track": "stable" + } + ], + "assets": [ + { "key": "macos_arm", "target": "aarch64-apple-darwin" }, + { "key": "macos_intel", "target": "x86_64-apple-darwin" }, + { "key": "linux", "target": "x86_64-unknown-linux-gnu" } + ] + }, + "winget": { + "workflow": "winget-publish.yml", + "dispatch_inputs": {}, + "identifier": "randlee.wyvern", + "installer_target": "x86_64-pc-windows-msvc" + } + } +} diff --git a/scripts/sync-sc-publish.sh b/scripts/sync-sc-publish.sh new file mode 100755 index 00000000..8a8ae89b --- /dev/null +++ b/scripts/sync-sc-publish.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +# Re-vendor publish workflows from ../sc-publish (single source of truth). +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +git_common="$(git -C "${repo_root}" rev-parse --git-common-dir)" +main_repo="$(cd "$(dirname "${git_common}")" && pwd)" +sc_publish_root="$(cd "${main_repo}/../sc-publish" && pwd)" +kit="${sc_publish_root}/plugins/sc-publish" +venv="${SC_PUBLISH_VENV:-${repo_root}/.sc-publish-venv}" +input="${repo_root}/release/install.json" + +if [[ ! -d "${sc_publish_root}/.git" ]]; then + echo "Missing sc-publish checkout at ${sc_publish_root}" >&2 + echo "Clone: git clone https://github.com/randlee/sc-publish.git ${sc_publish_root}" >&2 + exit 1 +fi + +( + cd "${sc_publish_root}" + git fetch origin + git checkout main + git pull --ff-only origin main +) + +if [[ ! -f "${input}" ]]; then + echo "Missing ${input}" >&2 + exit 1 +fi + +publish_python="$( + python3 "${kit}/.github/scripts/bootstrap_sc_compose.py" --venv "${venv}" +)" +"${publish_python}" "${kit}/install.py" --input "${input}" "${repo_root}" +"${publish_python}" "${kit}/install.py" --dry-run --input "${input}" "${repo_root}" +echo "sc-publish kit synced from ${sc_publish_root} @ $(git -C "${sc_publish_root}" rev-parse --short HEAD)" From 6d50753a02653bf6d310aa94a9615b0e2035f0f4 Mon Sep 17 00:00:00 2001 From: Rand Lee Date: Wed, 26 Aug 2026 18:22:19 -0700 Subject: [PATCH 02/12] docs(phase-J): harden sprint docs after plan-scope and critical review R1. Co-authored-by: Cursor --- docs/WINGET_SETUP.md | 8 +- .../.plan-hardening/guidelines-pass.json | 33 ++ .../.plan-hardening/plan-hardening-rounds.md | 7 + .../.plan-hardening/upstream-tracking.md | 20 +- docs/plans/phase-J/README.md | 30 +- .../plans/phase-J/j1-vendor-sc-publish-kit.md | 12 +- .../phase-J/j2-upstream-blockers-and-docs.md | 50 +- docs/plans/phase-J/j3-release-rehearsal.md | 45 +- docs/plans/phase-J/j4-production-cutover.md | 54 +- .../phase-J/publish-architecture-decision.md | 42 ++ scripts/sync-sc-publish.sh | 7 +- scripts/validate_release.py | 467 ------------------ 12 files changed, 191 insertions(+), 584 deletions(-) create mode 100644 docs/plans/phase-J/.plan-hardening/guidelines-pass.json create mode 100644 docs/plans/phase-J/.plan-hardening/plan-hardening-rounds.md create mode 100644 docs/plans/phase-J/publish-architecture-decision.md delete mode 100644 scripts/validate_release.py diff --git a/docs/WINGET_SETUP.md b/docs/WINGET_SETUP.md index c22047f2..d59d07b7 100644 --- a/docs/WINGET_SETUP.md +++ b/docs/WINGET_SETUP.md @@ -31,9 +31,13 @@ Declared in `release/install.json` → rendered `release/publish-artifacts.toml` | Secret | Purpose | |--------|---------| -| **`WINGET_GITHUB_TOKEN`** | PAT that can fork `microsoft/winget-pkgs` and open submission PRs | +| **`WINGET_GITHUB_TOKEN`** | PAT that can **fork** `microsoft/winget-pkgs` and **open PRs** | -The repository **`GITHUB_TOKEN` is not sufficient** for this external channel. +Recommended: classic PAT with `public_repo` (or fine-grained equivalent on fork +target). Preflight checks token liveness via GitHub `GET /user`; fork capability +is validated at submit time by `winget-releaser`. + +The repository **`GITHUB_TOKEN` is not sufficient** for winget submit. Preflight fails closed if `WINGET_GITHUB_TOKEN` is missing or not live. See also: [docs/RELEASE_SECRETS.md](RELEASE_SECRETS.md), diff --git a/docs/plans/phase-J/.plan-hardening/guidelines-pass.json b/docs/plans/phase-J/.plan-hardening/guidelines-pass.json new file mode 100644 index 00000000..be9e73d7 --- /dev/null +++ b/docs/plans/phase-J/.plan-hardening/guidelines-pass.json @@ -0,0 +1,33 @@ +{ + "status": "PASS", + "mode": "plan-hardening-guidelines-pass", + "round_id": "STEP1-R1", + "round_index": 1, + "reviewed_commit": "69e3ecf", + "previous_reviewed_commit": "", + "iterations": 1, + "docs_modified": [ + "docs/plans/phase-J/README.md", + "docs/plans/phase-J/j1-vendor-sc-publish-kit.md", + "docs/plans/phase-J/j2-upstream-blockers-and-docs.md", + "docs/plans/phase-J/j3-release-rehearsal.md", + "docs/plans/phase-J/j4-production-cutover.md", + "docs/plans/project-plan.md", + "docs/WINGET_SETUP.md" + ], + "docs_created": [ + "docs/plans/phase-J/.plan-hardening/upstream-tracking.md", + "release/install.json", + "scripts/sync-sc-publish.sh" + ], + "guidelines_applied": [ + "sprint docs authoritative; README index only", + "one list each: deliverables, acceptance criteria, required validation, paths to delete", + "explicit non-closure per sprint", + "install.json contract sample in j.1", + "production-ready wording; upstream waivers explicit in j.2", + "j.3 process-only with rehearsal-record deliverable" + ], + "ready_for_next_step": true, + "errors": [] +} diff --git a/docs/plans/phase-J/.plan-hardening/plan-hardening-rounds.md b/docs/plans/phase-J/.plan-hardening/plan-hardening-rounds.md new file mode 100644 index 00000000..fc2da609 --- /dev/null +++ b/docs/plans/phase-J/.plan-hardening/plan-hardening-rounds.md @@ -0,0 +1,7 @@ +# Phase J plan hardening rounds + +| Round | Step | Reviewer | reviewed_commit | status | blocking | important | minor | findings_hash | supersedes | Note | +|-------|------|----------|-----------------|--------|----------|-----------|-------|---------------|------------|------| +| STEP1-R1 | 1 | guidelines-pass | 69e3ecf | PASS | 0 | 0 | 0 | | | initial hardening | + +Cap: 2 reviewer iterations requested (scope + critical each). diff --git a/docs/plans/phase-J/.plan-hardening/upstream-tracking.md b/docs/plans/phase-J/.plan-hardening/upstream-tracking.md index 3d712794..4646afde 100644 --- a/docs/plans/phase-J/.plan-hardening/upstream-tracking.md +++ b/docs/plans/phase-J/.plan-hardening/upstream-tracking.md @@ -1,8 +1,16 @@ -# CR-001 / CR-002 upstream tracking (j.2) +# Waiver and upstream disposition (j.2 closeout) -| Blocker | Status | Link | Waiver signed | -|---------|--------|------|---------------| -| CR-001 Linux webview deps in kit jobs | pending | | | -| CR-002 Homebrew sc-compose renderer | pending | | | +Each blocker is exactly one state: -Updated in sprint j.2 closeout. +| Blocker | State | PR link | Signed waiver | Effect | +|---------|-------|---------|---------------|--------| +| CR-001 Linux webview deps | open | | | j.3/j.4 **not startable** until resolved | +| CR-002 Homebrew renderer | open | | | j.3/j.4 **not startable** until resolved | + +**Rules:** + +- `resolved` ⇒ merged sc-publish (or wyvern) PR link recorded; j.3 may proceed. +- `waived` ⇒ **blocks j.3 and j.4 entirely** (no re-sign escape). Phase J pauses until resolved. +- CR-002 resolved does **not** unblock Homebrew while CR-001 is still `open`. + +Updated in j.2 sprint closeout. diff --git a/docs/plans/phase-J/README.md b/docs/plans/phase-J/README.md index ae70514b..193d3959 100644 --- a/docs/plans/phase-J/README.md +++ b/docs/plans/phase-J/README.md @@ -54,7 +54,7 @@ Source of truth: sibling repo `../sc-publish` (`plugins/sc-publish`). ```bash # From wyvern repo root (any worktree): ./scripts/sync-sc-publish.sh -# → pulls ../sc-publish @ main, bootstraps sc-compose 1.5.0, runs install.py +# → pulls ../sc-publish @ pinned SHA (default 6aace27), bootstraps sc-compose, runs install.py # → copies agents/workflows/scripts verbatim; renders two manifests only ``` @@ -62,7 +62,7 @@ Source of truth: sibling repo `../sc-publish` (`plugins/sc-publish`). |-------|------| | `release/install.json` | **Only** hand-maintained publish contract (crates, targets, channels) | | `release/publish-artifacts.toml` | Rendered — repo-specific artifacts & destinations | -| `release/publish-channel-contracts.toml` | Vendored unchanged — secret names, liveness checks | +| `release/publish-channel-contracts.toml` | Rendered (channel-filtered) | | `.github/workflows/release*.yml` | Root + preflight + candidate | | `.github/workflows/*-publish.yml` | Post-release per-channel retries | | `.claude/agents/*-publisher.md` | ATM channel workers + coordinator | @@ -86,8 +86,8 @@ required validation. This README is the phase index only. |--------|-------|-----| | **j.1** | Vendor kit, `install.json`, sync script | [j1-vendor-sc-publish-kit.md](j1-vendor-sc-publish-kit.md) | | **j.2** | Upstream blockers, secrets, winget/docs | [j2-upstream-blockers-and-docs.md](j2-upstream-blockers-and-docs.md) | -| **j.3** | Release rehearsal on kit state machine | [j3-release-rehearsal.md](j3-release-rehearsal.md) | -| **j.4** | Production cutover; retire tag-push | [j4-production-cutover.md](j4-production-cutover.md) | +| **j.3** | First kit-managed release (`vX.Y.Z`) | [j3-release-rehearsal.md](j3-release-rehearsal.md) | +| **j.4** | Merge `integrate/phase-J` → `develop` | [j4-production-cutover.md](j4-production-cutover.md) | **Merge order → `integrate/phase-J`:** j.1 → j.2 → j.3 → j.4. @@ -123,15 +123,15 @@ Findings below are **in scope** for Phase J sprints. | ID | Finding | Plan response | Sprint | |----|---------|---------------|--------| -| CR-001 | Linux webview deps missing from kit release/preflight/crates jobs | Upstream sc-publish hook **or** documented waiver + CI-only gate until landed | j.2 | -| CR-002 | `renderer_archive_path = bin/wyvern` invalid for Homebrew formula render | Upstream kit must bootstrap sc-compose on runner; do not use product binary as renderer | j.2 | -| CR-003 | Homebrew UI path may flatten `share/wyvern/ui` | Fix `install.json` `homebrew_destination_components` → `["share","wyvern","ui"]` | j.2 | -| CR-004 | Tag-push trigger vs kit `workflow_dispatch` | j.4 retires tag-push; until then keep old workflow on `develop` if emergency ship needed | j.4 | -| CR-005 | Archive rename breaks README/tap/docs | Update consumer docs in j.2; call out breaking URL change in j.4 release notes | j.2, j.4 | -| CR-006 | `WINGET_GITHUB_TOKEN` required | Provision secret; update `docs/RELEASE_SECRETS.md` | j.2 | -| CR-007 | Wyvern-only gates stay in `ci.yml` | Explicit boundary — kit preflight does not replace ui-sync/boundaries | j.1 | -| CR-008 | `scripts/validate_release.py` half-migrated | Delete or rewrite against kit CLI; not wired into kit workflows | j.2 | -| CR-009 | `sync-sc-publish.sh` mutates sibling repo checkout | Document; optional pin to SHA instead of `main` | j.1 | +| CR-001 | Linux webview deps missing from kit jobs | **Must resolve** upstream before j.3; waiver blocks phase | j.2 | +| CR-002 | Homebrew renderer uses product binary | **Must resolve** upstream; update `install.json` renderer field | j.2 | +| CR-003 | Homebrew UI path flattening | `homebrew_destination_components` → `["share","wyvern","ui"]` | j.2 | +| CR-004 | Tag-push vs kit dispatch | Tag-push removed when j.1 lands on `integrate/phase-J` | j.1, [ADR](publish-architecture-decision.md) | +| CR-005 | Archive rename | Update README in j.2; release notes in j.3 | j.2, j.3 | +| CR-006 | `WINGET_GITHUB_TOKEN` required | Provision in j.2; bootstrap before j.3 if needed | j.2 | +| CR-007 | Wyvern gates stay in `ci.yml` | j.1 zero-diff on `ci.yml` | j.1 | +| CR-008 | Legacy `validate_release.py` | Delete in j.2 | j.2 | +| CR-009 | Moving sc-publish head | Pin `SC_PUBLISH_REF` in sync script | j.1 | **Hardening gate:** Each sprint doc passes plan-scope review and critical-plan review (Grok) before implementation merge. @@ -143,7 +143,9 @@ review (Grok) before implementation merge. 1. j.1–j.4 sprint acceptance criteria pass on `integrate/phase-J` (see sprint docs). 2. Plan hardening: `plan-scope-reviewer` + `critical-plan-reviewer` PASS; `quality-mgr` plan QA PASS. 3. `./scripts/sync-sc-publish.sh` dry-run exit **0** on final branch. -4. First kit-managed production release verified per j.4. +4. First kit-managed production release verified per **j.3**; `develop` integration per **j.4**. + +Architecture: [publish-architecture-decision.md](publish-architecture-decision.md). --- diff --git a/docs/plans/phase-J/j1-vendor-sc-publish-kit.md b/docs/plans/phase-J/j1-vendor-sc-publish-kit.md index 43553167..a090fd3b 100644 --- a/docs/plans/phase-J/j1-vendor-sc-publish-kit.md +++ b/docs/plans/phase-J/j1-vendor-sc-publish-kit.md @@ -37,7 +37,7 @@ entrypoint. Remove legacy bespoke publish files. | `.claude/agents/publisher.md` + channel publishers | ATM publish profile (vendored) | | `.cursor/agents/publisher.md` + publish skill/command | Cursor inline profile (vendored) | | `release/publish-artifacts.toml` | Rendered from `install.json` | -| `release/publish-channel-contracts.toml` | Vendored channel protocol | +| `release/publish-channel-contracts.toml` | Rendered from `install.json` (channel-filtered) | | `README.sc-publish.md` | Kit README (does not replace Wyvern `README.md`) | | `.gitignore` | Entry `.sc-publish-venv/` | @@ -75,18 +75,18 @@ Full file is authoritative at `release/install.json`. ## Acceptance criteria -1. `./scripts/sync-sc-publish.sh` resolves `../sc-publish` via git common dir, fetches `main`, installs with `--input release/install.json`, ends with dry-run exit **0**. +1. `./scripts/sync-sc-publish.sh` uses pinned `SC_PUBLISH_REF` (default `6aace27`), installs with `--input release/install.json`, dry-run exit **0**. 2. `release/install.json` declares five publishable crates (orders 1–5), `wyvern-mcp` unpublished, four release targets, binaries `wyvern` + `wyvern-viewer`, bundled `ui/`, channels **homebrew** + **winget** only. 3. Every deliverable path in the table above exists after sync (kit byte-for-byte copies). 4. Every path in **Paths to delete** is absent from the branch. 5. `python3 .github/scripts/release_artifacts.py validate-manifest --manifest release/publish-artifacts.toml --workspace-toml Cargo.toml` exits **0**. -6. `.github/workflows/ci.yml` is unchanged except documented path references (product gates stay in CI, not kit preflight). +6. `.github/workflows/ci.yml` has **zero diff** from pre-j.1 baseline (product gates unchanged). ## Non-closure (explicit) -- **j.1 does not** resolve upstream Linux deps (CR-001), Homebrew renderer (CR-002), or provision secrets — **j.2**. -- **j.1 does not** cut a release or retire tag-push workflow — **j.4**. -- Hand-editing vendored kit YAML is **forbidden**; fixes go to `install.json` or upstream sc-publish. +- **j.1 removes tag-push release** when merged to `integrate/phase-J` — kit `release.yml` replaces legacy trigger (see [publish-architecture-decision.md](publish-architecture-decision.md)). +- **j.1 does not** resolve CR-001/CR-002 or provision secrets — **j.2**. +- **j.1 does not** cut a release — **j.3**. ## Required validation diff --git a/docs/plans/phase-J/j2-upstream-blockers-and-docs.md b/docs/plans/phase-J/j2-upstream-blockers-and-docs.md index 37c720fc..1d27f196 100644 --- a/docs/plans/phase-J/j2-upstream-blockers-and-docs.md +++ b/docs/plans/phase-J/j2-upstream-blockers-and-docs.md @@ -12,9 +12,8 @@ depends_on: j.1 ## Goal -Close or **explicitly waive** Grok blockers before j.3 rehearsal. Update Wyvern -consumer docs and secret inventory for sc-publish (especially winget). Fix -`install.json` manifest issues without forking kit YAML. +Resolve CR-001 and CR-002 **before** j.3. Update consumer docs and provision +`WINGET_GITHUB_TOKEN`. Waivers **block** j.3/j.4 (see upstream-tracking). ## Hard dependencies @@ -24,41 +23,35 @@ consumer docs and secret inventory for sc-publish (especially winget). Fix | Path | Purpose | |------|---------| -| `release/install.json` | Homebrew UI path fix (`homebrew_destination_components`) | -| `docs/RELEASE_SECRETS.md` | Kit secret names incl. `WINGET_GITHUB_TOKEN` | -| `docs/WINGET_SETUP.md` | sc-publish winget leg + bootstrap + token model | -| `README.md` | Kit archive naming (`wyvern__.*`, `bin/` layout) | -| `docs/plans/phase-J/.plan-hardening/upstream-tracking.md` | Links to sc-publish PRs/issues for CR-001/CR-002 | -| GitHub repo secrets (owner) | `WINGET_GITHUB_TOKEN` provisioned or documented gap | +| `release/install.json` | Homebrew UI path; renderer field updated when CR-002 resolved | +| `docs/RELEASE_SECRETS.md` | Kit secrets incl. `WINGET_GITHUB_TOKEN` + PAT scope note | +| `docs/WINGET_SETUP.md` | sc-publish winget leg, bootstrap, token model | +| `README.md` | Kit archive naming | +| `docs/plans/phase-J/publish-architecture-decision.md` | ADR-linked decision record | +| `docs/plans/phase-J/.plan-hardening/upstream-tracking.md` | CR-001/CR-002 disposition table | ## Paths to delete | Path | Reason | |------|--------| -| `scripts/validate_release.py` | Or rewrite to kit CLI only; must not reference removed subcommands | - -## Upstream outcomes (choose one per blocker — document in closeout) - -| Blocker | Resolved | Waived | -|---------|----------|--------| -| CR-001 Linux webview deps | sc-publish PR merged; kit jobs install apt packages | j.3/j.4 blocked; emergency tag-push retained on `develop` until merged | -| CR-002 Homebrew renderer | sc-publish PR: bootstrap sc-compose on runner | `channels.homebrew` removed from `install.json`; tap manual until upstream | +| `scripts/validate_release.py` | Superseded by kit preflight + manifest CLI | ## Acceptance criteria -1. `release/install.json` sets `homebrew_destination_components` to `["share","wyvern","ui"]` for bundled UI; re-sync dry-run exit **0**. -2. `docs/RELEASE_SECRETS.md` documents `CARGO_REGISTRY_TOKEN`, `HOMEBREW_TAP_TOKEN`, **`WINGET_GITHUB_TOKEN`** with purpose (no secret values). -3. `docs/WINGET_SETUP.md` documents: `winget-publish.yml` dispatch, `WINGET_GITHUB_TOKEN`, asset pattern `wyvern_*_x86_64-pc-windows-msvc.zip`, one-time bootstrap, Microsoft review lag. -4. `README.md` artifact table matches kit archive names and `bin/` layout. -5. `scripts/validate_release.py` deleted **or** rewritten to use `.github/scripts/release_artifacts.py` subcommands that exist in kit @ synced version. -6. `upstream-tracking.md` records CR-001/CR-002 disposition (resolved PR link **or** signed waiver text). -7. `gh secret list` shows `WINGET_GITHUB_TOKEN` **or** sprint closeout documents owner provisioning as gate before j.3. +1. `release/install.json` sets `homebrew_destination_components` to `["share","wyvern","ui"]`; re-sync dry-run exit **0**. +2. CR-001 **resolved**: sc-publish PR merged; Linux webview apt packages run in kit release/preflight/crates jobs — link in upstream-tracking. +3. CR-002 **resolved**: sc-publish PR merged; Homebrew leg bootstraps sc-compose on runner; `release/install.json` **does not** use product binary as renderer (`renderer_archive_path` removed or names kit renderer path per upstream contract). +4. `docs/RELEASE_SECRETS.md` documents `CARGO_REGISTRY_TOKEN`, `HOMEBREW_TAP_TOKEN`, `WINGET_GITHUB_TOKEN` (classic or fine-grained PAT; minimum: fork `microsoft/winget-pkgs`, open PRs). +5. `docs/WINGET_SETUP.md` matches kit: post-release dispatch, token, asset pattern, bootstrap requirement, review lag. +6. `README.md` artifact table uses `wyvern__.*` and `bin/` layout. +7. `gh secret list` includes **`WINGET_GITHUB_TOKEN`** (j.2 **cannot** close without it). +8. If `randlee.wyvern` absent from `winget-pkgs`, owner completes one-time bootstrap **before** j.3 (document completion in closeout). +9. upstream-tracking shows CR-001 and CR-002 both **`resolved`** (not waived). ## Non-closure (explicit) -- **j.2 does not** execute release rehearsal — **j.3**. -- **j.2 does not** submit winget bootstrap PR to `microsoft/winget-pkgs` unless owner chooses to do so as prep; j.3 AC covers submission attempt. -- If CR-001 **and** CR-002 are both waived, j.3 must not merge until waiver is signed in closeout. +- **j.2 does not** run kit release — **j.3**. +- **Waivers block the phase** — do not sign waivers to “unblock” j.3. ## Required validation @@ -66,6 +59,7 @@ consumer docs and secret inventory for sc-publish (especially winget). Fix ./scripts/sync-sc-publish.sh python3 .github/scripts/release_artifacts.py validate-manifest \ --manifest release/publish-artifacts.toml --workspace-toml Cargo.toml +gh secret list | rg 'WINGET_GITHUB_TOKEN' +test ! -f scripts/validate_release.py rg 'WINGET_GITHUB_TOKEN' docs/RELEASE_SECRETS.md docs/WINGET_SETUP.md -rg 'wyvern_.*_x86_64-pc-windows-msvc' docs/WINGET_SETUP.md README.md ``` diff --git a/docs/plans/phase-J/j3-release-rehearsal.md b/docs/plans/phase-J/j3-release-rehearsal.md index 657b3dba..38aa0881 100644 --- a/docs/plans/phase-J/j3-release-rehearsal.md +++ b/docs/plans/phase-J/j3-release-rehearsal.md @@ -1,57 +1,54 @@ --- id: j.3 -title: Kit release rehearsal +title: First kit-managed release status: planning -branch: feature/phase-J-j3-rehearsal -worktree: ../wyvern-worktrees/plan/phase-J-j3-rehearsal +branch: feature/phase-J-j3-first-release +worktree: ../wyvern-worktrees/feature/phase-J-j3-first-release target: integrate/phase-J depends_on: j.2 --- -# Sprint j.3 — Kit release rehearsal +# Sprint j.3 — First kit-managed release ## Goal -Execute one full sc-publish release drill on a rehearsal version without -surprising production download consumers. Prove candidate → `main` → preflight → -release dispatch → assets → post-release channel legs. +Cut the **first production semver** using the sc-publish state machine on +`integrate/phase-J` → `main`. Uses real `vX.Y.Z` (kit accepts only +`X.Y.Z` / `vX.Y.Z`); burns a production version. ## Hard dependencies -- j.2 closeout: CR-001/CR-002 resolved **or** signed waiver +- j.2 closeout: CR-001 and CR-002 **resolved** (upstream-tracking) - `WINGET_GITHUB_TOKEN` provisioned (j.2 AC #7) +- Winget bootstrap complete if first package (j.2 AC #8) - GitHub environment `crates-io` exists ## Deliverables | Path | Purpose | |------|---------| -| `docs/plans/phase-J/.plan-hardening/rehearsal-record.md` | Run IDs, tag, channel outcomes, waiver notes | - -No product code changes expected unless rehearsal exposes packaging bug (fix in-place, document in record). +| `docs/plans/phase-J/.plan-hardening/first-release-record.md` | Run IDs, tag, per-channel outcomes | ## Acceptance criteria -1. `release-candidate.yml` dispatched; tag `release-candidate-vX.Y.Z` on `origin/develop`. -2. Branch `release/vX.Y.Z` merged to **`main`** with version lockstep. +1. `release-candidate.yml` dispatched; tag `release-candidate-vX.Y.Z` on branch containing kit workflows. +2. `release/vX.Y.Z` merged to **`main`** with version lockstep. 3. `release-preflight.yml` green on exact `main` SHA. -4. `release.yml` `target=production` creates `vX.Y.Z` and GitHub Release with kit asset names. -5. Each archive contains `bin/wyvern`, `bin/wyvern-viewer`, and `share/wyvern/ui/{message,input,markdown,question,chrome}/index.html`. -6. crates.io publish leg succeeds or detect-and-skips already-published crates. -7. `homebrew-publish.yml` succeeds **or** waived per j.2 closeout (failure without waiver = sprint fail). -8. `winget-publish.yml` reaches submission (PR opened or confirmed skip probe); missing token = sprint fail. -9. Re-dispatch one post-release leg; detect-and-skip prevents duplicate publish. -10. `rehearsal-record.md` lists all workflow run URLs and go/no-go for j.4. +4. `release.yml` `target=production` creates `vX.Y.Z` and GitHub Release (kit asset names). +5. Archives contain `bin/wyvern`, `bin/wyvern-viewer`, `share/wyvern/ui/{message,input,markdown,question,chrome}/index.html`. +6. crates.io publish succeeds or detect-and-skips. +7. If `install.json` declares `homebrew`: `homebrew-publish.yml` succeeds. If channel absent, leg not dispatched (document skip). +8. `winget-publish.yml` opens submission PR (skip-probe allowed **only** when version already submitted); auth failure = fail. +9. Re-dispatch one post-release leg; detect-and-skip works. +10. `first-release-record.md` documents channels tested/skipped and go/no-go for j.4. ## Non-closure (explicit) -- Microsoft winget **install visibility** is not required same-day; submission success counts (per `WINGET_SETUP.md`). -- Rehearsal version may be pre-release bump; need not be the final marketing version. +- Microsoft winget install visibility may lag; submission success counts. +- Packaging bug found during release: fix on release branch, document in record. ## Required validation -Evidence in `rehearsal-record.md`: - ```bash gh release view vX.Y.Z --json assets curl -fsS -A wyvern-check "https://crates.io/api/v1/crates/wyvern-cli/X.Y.Z" diff --git a/docs/plans/phase-J/j4-production-cutover.md b/docs/plans/phase-J/j4-production-cutover.md index b2eea660..cc6de87b 100644 --- a/docs/plans/phase-J/j4-production-cutover.md +++ b/docs/plans/phase-J/j4-production-cutover.md @@ -1,66 +1,52 @@ --- id: j.4 -title: Production cutover +title: Integrate phase-J to develop status: planning -branch: feature/phase-J-j4-cutover -worktree: ../wyvern-worktrees/feature/phase-J-j4-cutover -target: integrate/phase-J +branch: feature/phase-J-j4-integrate +worktree: ../wyvern-worktrees/feature/phase-J-j4-integrate +target: develop depends_on: j.3 --- -# Sprint j.4 — Production cutover +# Sprint j.4 — Integrate phase-J to develop ## Goal -Merge `integrate/phase-J` → `develop`. Cut first **production** release fully on -sc-publish. Retire tag-push release workflow. +Merge `integrate/phase-J` → `develop` after first kit release (j.3). Verify no +legacy tag-push release triggers remain. Open `main` → `develop` back-merge. ## Hard dependencies -- j.3 `rehearsal-record.md` go/no-go = **go** -- No open CR-001/CR-002 waivers unless explicitly re-signed +- j.3 `first-release-record.md` go/no-go = **go** +- CR-001/CR-002 **resolved** (never waived) ## Deliverables | Path | Purpose | |------|---------| -| `.github/workflows/release.yml` (kit) | Sole release entry; tag-push trigger removed | -| `release/release-notes.md` | Breaking archive URL change called out | -| `docs/plans/phase-J/.plan-hardening/cutover-record.md` | Production run IDs, channel verification | +| `docs/plans/phase-J/.plan-hardening/integrate-record.md` | Merge PR, workflow grep evidence | ## Paths to delete -| Path | Reason | -|------|--------| -| Tag-push `on.push.tags: v*` trigger | Replaced by kit `workflow_dispatch` on `main` | - -Implementation: remove legacy trigger from any remaining bespoke release workflow file; kit `release.yml` must be the only root release workflow. - -## Breaking change (normative) - -| Old (v0.5.0) | New (kit) | -|--------------|-----------| -| `wyvern-macos-aarch64.tar.gz` | `wyvern_X.Y.Z_aarch64-apple-darwin.tar.gz` | -| `wyvern-windows.zip` | `wyvern_X.Y.Z_x86_64-pc-windows-msvc.zip` | -| Binaries at archive root | `bin/wyvern`, `bin/wyvern-viewer` | +None — j.1 removed legacy publish files; kit `release.yml` has no tag-push trigger. ## Acceptance criteria 1. `integrate/phase-J` merged to `develop` after phase-end QA PASS. -2. No workflow on `develop` triggers release on tag push alone. -3. Production `vX.Y.Z` shipped via kit state machine (same steps as j.3). -4. Release notes document archive rename and winget review lag. -5. Verified: crates.io, GitHub Release (four targets), Homebrew tap bump (unless j.2 Homebrew waiver still active — then **fail**), winget submission success. -6. `main` → `develop` back-merge PR opened post-release (publisher policy). +2. **No** file in `.github/workflows/` on `develop` contains `push:` + `tags:` release trigger (grep all workflows). +3. j.3 production tag and channels remain verified (no regression). +4. Release notes on j.3 tag document archive rename (if not already). +5. `main` → `develop` back-merge PR opened (publisher policy). ## Non-closure (explicit) -- **j.4 does not** merge back-merge PR — publisher/owner after CI green. +- **j.4 does not** merge back-merge PR. ## Required validation ```bash -! gh workflow view release.yml --yaml | rg 'push:\s*$' -A1 | rg 'tags:' -gh release view vX.Y.Z --json assets -brew info wyvern # tap version matches X.Y.Z when Homebrew channel active +! rg -l 'tags:\s*\n\s*-\s*"v\*"' .github/workflows/ 2>/dev/null +! rg 'push:' .github/workflows/*.yml -A2 | rg 'tags:' ``` + +If Homebrew channel active: `brew info wyvern` shows j.3 version. diff --git a/docs/plans/phase-J/publish-architecture-decision.md b/docs/plans/phase-J/publish-architecture-decision.md new file mode 100644 index 00000000..0a0315c3 --- /dev/null +++ b/docs/plans/phase-J/publish-architecture-decision.md @@ -0,0 +1,42 @@ +# Phase J — Publish architecture decision + +**Status:** Accepted (planning) +**Phase:** J — sc-publish migration + +## Context + +Wyvern moves from bespoke tag-push release workflows to the shared +[`sc-publish`](https://github.com/randlee/sc-publish) kit for consistency with +other Rust repos. + +## Decision + +1. **Kit source:** Vendored byte-for-byte from `../sc-publish` at a **pinned SHA** + recorded in `scripts/sync-sc-publish.sh`. Local edits to copied kit files are + forbidden; changes go to upstream sc-publish or `release/install.json`. +2. **Consumer contract:** `release/install.json` is the only wyvern-owned publish + input; `install.py` renders `release/publish-artifacts.toml` and + `release/publish-channel-contracts.toml` (channel-filtered). +3. **Production tags:** Immutable `vX.Y.Z` tags are created **only** by kit + `release.yml` `workflow_dispatch` on **`main`**. No local `git tag` / `git push + --tags` for releases. +4. **Trigger cutover:** Tag-push `on.push.tags: v*` is removed when kit workflows + land on **`integrate/phase-J`** (j.1). There is no parallel emergency tag-push + workflow after j.1 merges. +5. **Winget credential:** `WINGET_GITHUB_TOKEN` (PAT with fork/PR rights to + `microsoft/winget-pkgs`) is **required**. Repository `GITHUB_TOKEN` is **not** + used for winget submit. +6. **Product gates:** `ci.yml` retains wyvern-only checks (boundaries, ui-sync, + share-sync). Kit preflight does not replace them. + +## Consequences + +- First kit release (j.3) uses a **real semver** and production channels; there is + no separate “safe rehearsal” semver in the current kit. +- CR-001 (Linux webview deps) and CR-002 (Homebrew renderer) must be **resolved** + before j.3; waivers block the phase. + +## References + +- [docs/plans/phase-J/README.md](plans/phase-J/README.md) +- [README.sc-publish.md](../../README.sc-publish.md) diff --git a/scripts/sync-sc-publish.sh b/scripts/sync-sc-publish.sh index 8a8ae89b..f4bdc851 100755 --- a/scripts/sync-sc-publish.sh +++ b/scripts/sync-sc-publish.sh @@ -16,11 +16,12 @@ if [[ ! -d "${sc_publish_root}/.git" ]]; then exit 1 fi +SC_PUBLISH_REF="${SC_PUBLISH_REF:-6aace27}" + ( cd "${sc_publish_root}" git fetch origin - git checkout main - git pull --ff-only origin main + git checkout "${SC_PUBLISH_REF}" 2>/dev/null || git checkout main && git pull --ff-only origin main ) if [[ ! -f "${input}" ]]; then @@ -33,4 +34,4 @@ publish_python="$( )" "${publish_python}" "${kit}/install.py" --input "${input}" "${repo_root}" "${publish_python}" "${kit}/install.py" --dry-run --input "${input}" "${repo_root}" -echo "sc-publish kit synced from ${sc_publish_root} @ $(git -C "${sc_publish_root}" rev-parse --short HEAD)" +echo "sc-publish kit synced from ${sc_publish_root} @ $(git -C "${sc_publish_root}" rev-parse --short HEAD) (pin ${SC_PUBLISH_REF})" diff --git a/scripts/validate_release.py b/scripts/validate_release.py deleted file mode 100644 index 4a9765e5..00000000 --- a/scripts/validate_release.py +++ /dev/null @@ -1,467 +0,0 @@ -#!/usr/bin/env python3 -"""Wyvern release validation suite (atm-core pattern, wyvern scope).""" - -from __future__ import annotations - -import argparse -import json -import subprocess -import sys -import tempfile -import tomllib -from dataclasses import asdict, dataclass -from datetime import datetime, timezone -from pathlib import Path - -REQUIRED_RELEASE_FILES = ( - "release/publish-artifacts.toml", - "scripts/release_gate.sh", - "scripts/release_artifacts.py", - "docs/release-inventory-schema.json", - "release/RELEASE-NOTES-TEMPLATE.md", -) -REQUIRED_RELEASE_BINARIES = ("wyvern",) -INVENTORY_REQUIRED_TOP = ("releaseVersion", "releaseTag", "releaseCommit", "generatedAt", "items") -INVENTORY_REQUIRED_ITEM = ("artifact", "version", "sourceRef", "publishTarget", "verifyCommands", "required") - - -@dataclass -class Finding: - check: str - severity: str - summary: str - detail: str = "" - command: list[str] | None = None - exit_code: int | None = None - - @property - def blocks(self) -> bool: - return self.severity == "error" - - -def repo_root() -> Path: - return Path(__file__).resolve().parent.parent - - -def utc_now() -> str: - return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") - - -def workspace_version(root: Path) -> str: - cargo = tomllib.loads((root / "Cargo.toml").read_text(encoding="utf-8")) - version = cargo.get("workspace", {}).get("package", {}).get("version") - if not isinstance(version, str) or not version.strip(): - raise SystemExit("workspace.package.version missing from Cargo.toml") - return version - - -def current_ref(root: Path) -> str: - completed = subprocess.run( - ["git", "rev-parse", "--abbrev-ref", "HEAD"], - cwd=root, - capture_output=True, - text=True, - encoding="utf-8", - check=False, - ) - if completed.returncode != 0: - return "UNKNOWN" - return completed.stdout.strip() or "UNKNOWN" - - -def run_capture(cmd: list[str], *, cwd: Path) -> subprocess.CompletedProcess[str]: - return subprocess.run( - cmd, - cwd=cwd, - check=False, - capture_output=True, - text=True, - encoding="utf-8", - errors="replace", - ) - - -def append_completed_findings( - findings: list[Finding], - check: str, - completed: subprocess.CompletedProcess[str], - success_summary: str, - failure_summary: str, -) -> None: - if completed.returncode == 0: - if completed.stdout.strip(): - sys.stdout.write(completed.stdout) - if completed.stderr.strip(): - sys.stderr.write(completed.stderr) - return - if completed.stdout.strip(): - sys.stdout.write(completed.stdout) - if completed.stderr.strip(): - sys.stderr.write(completed.stderr) - findings.append( - Finding( - check=check, - severity="error", - summary=failure_summary, - detail=(completed.stderr or completed.stdout).strip(), - command=completed.args if isinstance(completed.args, list) else None, - exit_code=completed.returncode, - ) - ) - - -def validate_support_files(root: Path, findings: list[Finding]) -> None: - missing = [path for path in REQUIRED_RELEASE_FILES if not (root / path).exists()] - if missing: - findings.append( - Finding( - check="support-files", - severity="error", - summary="missing required release support files", - detail=", ".join(missing), - ) - ) - - -def validate_lint(root: Path, findings: list[Finding]) -> None: - for check, cmd, summary in ( - ("fmt", ["cargo", "fmt", "--all", "--", "--check"], "cargo fmt --check failed"), - ("clippy", ["cargo", "clippy", "--workspace", "--", "-D", "warnings"], "cargo clippy failed"), - ): - completed = run_capture(cmd, cwd=root) - append_completed_findings(findings, check, completed, f"{check} passed", summary) - - -def validate_manifest(root: Path, findings: list[Finding]) -> None: - commands = ( - ( - "manifest-coverage", - [ - "python3", - "scripts/release_artifacts.py", - "validate-manifest", - "--manifest", - "release/publish-artifacts.toml", - "--workspace-toml", - "Cargo.toml", - ], - "manifest coverage validation failed", - ), - ( - "preflight-modes", - [ - "python3", - "scripts/release_artifacts.py", - "validate-preflight-checks", - "--manifest", - "release/publish-artifacts.toml", - "--workspace-toml", - "Cargo.toml", - ], - "preflight mode validation failed", - ), - ( - "publish-order", - [ - "python3", - "scripts/release_artifacts.py", - "validate-publish-order", - "--manifest", - "release/publish-artifacts.toml", - "--workspace-toml", - "Cargo.toml", - ], - "publish-order validation failed", - ), - ) - for check, cmd, summary in commands: - completed = run_capture(cmd, cwd=root) - append_completed_findings(findings, check, completed, f"{check} passed", summary) - - -def validate_release_binaries(root: Path, findings: list[Finding]) -> None: - completed = run_capture( - [ - "python3", - "scripts/release_artifacts.py", - "validate-release-binaries", - "--manifest", - "release/publish-artifacts.toml", - *sum((["--required", binary] for binary in REQUIRED_RELEASE_BINARIES), []), - ], - cwd=root, - ) - append_completed_findings( - findings, - "release-binaries", - completed, - "required release binaries validated", - "required release binaries missing from manifest", - ) - - -def validate_publish_surface( - root: Path, - version: str, - findings: list[Finding], - *, - enforce_release_version: bool, -) -> None: - if enforce_release_version: - unpublished = run_capture( - [ - "python3", - "scripts/release_artifacts.py", - "check-version-unpublished", - "--manifest", - "release/publish-artifacts.toml", - "--version", - version, - ], - cwd=root, - ) - append_completed_findings( - findings, - "publish-version-unpublished", - unpublished, - "release version is unpublished", - "release version already published", - ) - else: - findings.append( - Finding( - check="publish-version-unpublished", - severity="warning", - summary="release version publication check skipped outside explicit release-candidate mode", - ) - ) - - modes = { - "full": [ - "python3", - "scripts/release_artifacts.py", - "list-preflight", - "--manifest", - "release/publish-artifacts.toml", - "--mode", - "full", - ], - "locked": [ - "python3", - "scripts/release_artifacts.py", - "list-preflight", - "--manifest", - "release/publish-artifacts.toml", - "--mode", - "locked", - ], - } - crates_by_mode: dict[str, list[str]] = {} - for mode, cmd in modes.items(): - completed = run_capture(cmd, cwd=root) - if completed.returncode != 0: - append_completed_findings( - findings, - f"publish-surface-{mode}-list", - completed, - f"{mode} preflight list generated", - f"{mode} preflight list generation failed", - ) - crates_by_mode[mode] = [] - continue - crates_by_mode[mode] = [line.strip() for line in completed.stdout.splitlines() if line.strip()] - - for crate in crates_by_mode.get("full", []): - for cmd, check_name, summary in ( - ( - ["cargo", "package", "-p", crate, "--locked", "--no-verify"], - f"cargo-package-{crate}", - f"`cargo package` failed for {crate}", - ), - ( - ["cargo", "publish", "--dry-run", "-p", crate, "--locked", "--no-verify"], - f"cargo-publish-dry-run-{crate}", - f"`cargo publish --dry-run` failed for {crate}", - ), - ): - completed = run_capture(cmd, cwd=root) - append_completed_findings(findings, check_name, completed, f"{check_name} passed", summary) - - for crate in crates_by_mode.get("locked", []): - completed = run_capture(["cargo", "check", "-p", crate, "--locked"], cwd=root) - append_completed_findings( - findings, - f"cargo-check-{crate}", - completed, - f"cargo check passed for {crate}", - f"`cargo check --locked` failed for {crate}", - ) - - -def validate_inventory(root: Path, version: str, findings: list[Finding]) -> None: - tag = f"v{version}" - commit_result = run_capture(["git", "rev-parse", "HEAD"], cwd=root) - if commit_result.returncode != 0: - append_completed_findings( - findings, - "inventory-commit", - commit_result, - "release commit resolved", - "release commit resolution failed", - ) - return - commit = commit_result.stdout.strip() - with tempfile.TemporaryDirectory(prefix="wyvern-release-inventory-") as tmpdir: - output = Path(tmpdir) / "release-inventory.json" - completed = run_capture( - [ - "python3", - "scripts/release_artifacts.py", - "emit-inventory", - "--manifest", - "release/publish-artifacts.toml", - "--version", - version, - "--tag", - tag, - "--commit", - commit, - "--source-ref", - f"refs/heads/{current_ref(root)}", - "--generated-at", - utc_now(), - "--output", - str(output), - ], - cwd=root, - ) - if completed.returncode != 0: - append_completed_findings( - findings, - "inventory-generate", - completed, - "release inventory generated", - "release inventory generation failed", - ) - return - inventory = json.loads(output.read_text(encoding="utf-8")) - - missing_top = [field for field in INVENTORY_REQUIRED_TOP if field not in inventory] - if missing_top: - findings.append( - Finding( - check="inventory-shape", - severity="error", - summary="inventory missing required top-level fields", - detail=", ".join(missing_top), - ) - ) - return - items = inventory.get("items", []) - if not isinstance(items, list) or not items: - findings.append( - Finding( - check="inventory-shape", - severity="error", - summary="inventory.items must be a non-empty list", - ) - ) - return - item_errors: list[str] = [] - for idx, item in enumerate(items): - if not isinstance(item, dict): - item_errors.append(f"items[{idx}] must be an object") - continue - for field in INVENTORY_REQUIRED_ITEM: - if field not in item: - item_errors.append(f"items[{idx}] missing {field}") - if item_errors: - findings.append( - Finding( - check="inventory-shape", - severity="error", - summary="inventory shape validation failed", - detail="; ".join(item_errors), - ) - ) - - -def write_findings(root: Path, version: str, findings_path: Path, findings: list[Finding]) -> None: - payload = { - "generatedAt": utc_now(), - "branch": current_ref(root), - "version": version, - "status": "fail" if any(f.blocks for f in findings) else "pass", - "findings": [asdict(f) | {"blocks": f.blocks} for f in findings], - } - findings_path.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8") - - -def build_parser() -> argparse.ArgumentParser: - parser = argparse.ArgumentParser(description="Run the Wyvern release validation suite") - parser.add_argument( - "target", - nargs="?", - default="all", - choices=("all", "lint", "support-files", "manifest", "publish-surface", "release-binaries", "inventory"), - ) - parser.add_argument("--version", help="Release version to validate; defaults to workspace.package.version") - parser.add_argument("--findings", default="release-findings.json", help="Path to findings JSON output") - return parser - - -def main(argv: list[str] | None = None) -> int: - args = build_parser().parse_args(argv) - root = repo_root() - explicit_version = args.version is not None - version = args.version or workspace_version(root) - if version != workspace_version(root): - raise SystemExit( - f"release version mismatch: expected workspace version {workspace_version(root)}, got {version}" - ) - - findings: list[Finding] = [] - actions = { - "support-files": lambda: validate_support_files(root, findings), - "lint": lambda: validate_lint(root, findings), - "manifest": lambda: validate_manifest(root, findings), - "publish-surface": lambda: validate_publish_surface( - root, - version, - findings, - enforce_release_version=explicit_version, - ), - "release-binaries": lambda: validate_release_binaries(root, findings), - "inventory": lambda: validate_inventory(root, version, findings), - } - - findings_path = root / args.findings - try: - if args.target == "all": - for target in ( - "support-files", - "lint", - "manifest", - "publish-surface", - "release-binaries", - "inventory", - ): - print(f"== validate {target} ==") - actions[target]() - else: - actions[args.target]() - finally: - write_findings(root, version, findings_path, findings) - print(f"wrote findings: {findings_path}") - - blockers = [finding for finding in findings if finding.blocks] - if blockers: - print("release validation blockers:") - for finding in blockers: - print(f"- [{finding.check}] {finding.summary}") - return 1 - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) From af6503cec22b932ab3c406ac97640eea03a517bc Mon Sep 17 00:00:00 2001 From: Rand Lee Date: Wed, 26 Aug 2026 18:27:34 -0700 Subject: [PATCH 03/12] docs(phase-J): complete plan hardening R2 after critical review. Pin sc-publish sync to expected SHA without fast-forward to main; rename j3 sprint doc; record scope/critical review artifacts for iteration 2. Co-authored-by: Cursor --- docs/plans/phase-J/.plan-hardening/crit-r1.json | 10 ++++++++++ .../phase-J/.plan-hardening/plan-hardening-rounds.md | 9 ++++++--- docs/plans/phase-J/.plan-hardening/scope-r1.json | 10 ++++++++++ docs/plans/phase-J/README.md | 4 ++-- docs/plans/phase-J/j1-vendor-sc-publish-kit.md | 3 ++- ...-release-rehearsal.md => j3-first-kit-release.md} | 0 docs/plans/project-plan.md | 6 +++--- scripts/sync-sc-publish.sh | 12 +++++++++++- 8 files changed, 44 insertions(+), 10 deletions(-) create mode 100644 docs/plans/phase-J/.plan-hardening/crit-r1.json create mode 100644 docs/plans/phase-J/.plan-hardening/scope-r1.json rename docs/plans/phase-J/{j3-release-rehearsal.md => j3-first-kit-release.md} (100%) diff --git a/docs/plans/phase-J/.plan-hardening/crit-r1.json b/docs/plans/phase-J/.plan-hardening/crit-r1.json new file mode 100644 index 00000000..d0bdcf68 --- /dev/null +++ b/docs/plans/phase-J/.plan-hardening/crit-r1.json @@ -0,0 +1,10 @@ +{ + "status": "FAIL", + "mode": "critical-plan-review", + "reviewer": "critical-plan-reviewer", + "round_id": "STEP3-R1", + "round_index": 1, + "reviewed_commit": "69e3ecf", + "findings_hash": "STEP3-R1-69e3ecf-001:009", + "finding_count": { "blocking": 3, "important": 6 } +} diff --git a/docs/plans/phase-J/.plan-hardening/plan-hardening-rounds.md b/docs/plans/phase-J/.plan-hardening/plan-hardening-rounds.md index fc2da609..1bf4ee92 100644 --- a/docs/plans/phase-J/.plan-hardening/plan-hardening-rounds.md +++ b/docs/plans/phase-J/.plan-hardening/plan-hardening-rounds.md @@ -2,6 +2,9 @@ | Round | Step | Reviewer | reviewed_commit | status | blocking | important | minor | findings_hash | supersedes | Note | |-------|------|----------|-----------------|--------|----------|-----------|-------|---------------|------------|------| -| STEP1-R1 | 1 | guidelines-pass | 69e3ecf | PASS | 0 | 0 | 0 | | | initial hardening | - -Cap: 2 reviewer iterations requested (scope + critical each). +| STEP1-R1 | 1 | guidelines-pass | 69e3ecf | PASS | 0 | 0 | 0 | | | initial | +| STEP2-R1 | 2 | plan-scope-reviewer | 69e3ecf | FAIL | 3 | 5 | 4 | phaseJ-scope-r1 | | | +| STEP3-R1 | 4 | critical-plan-reviewer (Grok) | 69e3ecf | FAIL | 3 | 6 | 3 | STEP3-R1 | | | +| STEP2-R2 | 2 | plan-scope-reviewer | 6d50753 | PASS | 0 | 0 | 0 | phase-j-r2-clean | STEP2-R1 | | +| STEP3-R2 | 4 | critical-plan-reviewer (Grok) | 6d50753 | FAIL | 0 | 1 | 3 | STEP3-R2-001 | STEP3-R1 | kit pin script | +| STEP5-R1 | 5 | arch-ctm (inline) | pending | — | — | — | — | | STEP3-R2 | pin fix applied | diff --git a/docs/plans/phase-J/.plan-hardening/scope-r1.json b/docs/plans/phase-J/.plan-hardening/scope-r1.json new file mode 100644 index 00000000..6b48e957 --- /dev/null +++ b/docs/plans/phase-J/.plan-hardening/scope-r1.json @@ -0,0 +1,10 @@ +{ + "status": "FAIL", + "mode": "plan-scope-review", + "reviewer": "plan-scope-reviewer", + "round_id": "STEP2-R1", + "round_index": 1, + "reviewed_commit": "69e3ecf", + "findings_hash": "phaseJ-scope-r1-69e3ecf", + "finding_count": { "blocking": 3, "important": 5 } +} diff --git a/docs/plans/phase-J/README.md b/docs/plans/phase-J/README.md index 193d3959..10817e37 100644 --- a/docs/plans/phase-J/README.md +++ b/docs/plans/phase-J/README.md @@ -86,7 +86,7 @@ required validation. This README is the phase index only. |--------|-------|-----| | **j.1** | Vendor kit, `install.json`, sync script | [j1-vendor-sc-publish-kit.md](j1-vendor-sc-publish-kit.md) | | **j.2** | Upstream blockers, secrets, winget/docs | [j2-upstream-blockers-and-docs.md](j2-upstream-blockers-and-docs.md) | -| **j.3** | First kit-managed release (`vX.Y.Z`) | [j3-release-rehearsal.md](j3-release-rehearsal.md) | +| **j.3** | First kit-managed release (`vX.Y.Z`) | [j3-first-kit-release.md](j3-first-kit-release.md) | | **j.4** | Merge `integrate/phase-J` → `develop` | [j4-production-cutover.md](j4-production-cutover.md) | **Merge order → `integrate/phase-J`:** j.1 → j.2 → j.3 → j.4. @@ -179,7 +179,7 @@ python3 .github/scripts/release_artifacts.py validate-manifest \ curl -fsS -A wyvern-check "https://crates.io/api/v1/crates/wyvern-cli/X.Y.Z" gh release view vX.Y.Z --json assets curl -fsS "https://raw.githubusercontent.com/randlee/homebrew-tap/main/Formula/wyvern.rb" | rg version -gh workflow run winget-publish.yml -f tag=vX.Y.Z # when token provisioned +gh workflow run winget-publish.yml -f tag=vX.Y.Z --ref main ``` --- diff --git a/docs/plans/phase-J/j1-vendor-sc-publish-kit.md b/docs/plans/phase-J/j1-vendor-sc-publish-kit.md index a090fd3b..9f6a634e 100644 --- a/docs/plans/phase-J/j1-vendor-sc-publish-kit.md +++ b/docs/plans/phase-J/j1-vendor-sc-publish-kit.md @@ -75,7 +75,7 @@ Full file is authoritative at `release/install.json`. ## Acceptance criteria -1. `./scripts/sync-sc-publish.sh` uses pinned `SC_PUBLISH_REF` (default `6aace27`), installs with `--input release/install.json`, dry-run exit **0**. +1. `./scripts/sync-sc-publish.sh` checks out pinned `SC_PUBLISH_REF` (default `6aace27`) at exact SHA `SC_PUBLISH_EXPECTED_SHA` (default `6aace27a…`); no pull to `main` after checkout; dry-run exit **0**. 2. `release/install.json` declares five publishable crates (orders 1–5), `wyvern-mcp` unpublished, four release targets, binaries `wyvern` + `wyvern-viewer`, bundled `ui/`, channels **homebrew** + **winget** only. 3. Every deliverable path in the table above exists after sync (kit byte-for-byte copies). 4. Every path in **Paths to delete** is absent from the branch. @@ -92,6 +92,7 @@ Full file is authoritative at `release/install.json`. ```bash ./scripts/sync-sc-publish.sh +test "$(git -C "$(git rev-parse --git-common-dir | xargs dirname)/../sc-publish" rev-parse HEAD)" = "6aace27b78aa6487c9185d831e1ae70f407fded9" python3 .github/scripts/release_artifacts.py validate-manifest \ --manifest release/publish-artifacts.toml --workspace-toml Cargo.toml ! git grep -n 'scripts/release_artifacts.py' -- ':!docs/' ':!.github/scripts/tests/' diff --git a/docs/plans/phase-J/j3-release-rehearsal.md b/docs/plans/phase-J/j3-first-kit-release.md similarity index 100% rename from docs/plans/phase-J/j3-release-rehearsal.md rename to docs/plans/phase-J/j3-first-kit-release.md diff --git a/docs/plans/project-plan.md b/docs/plans/project-plan.md index 57d2992f..54117dd3 100644 --- a/docs/plans/project-plan.md +++ b/docs/plans/project-plan.md @@ -315,10 +315,10 @@ Phase J sprint PRs target `integrate/phase-J`. Sprint authority: |--------|-------------|-----| | j.1 | Vendor kit + `install.json` + sync script | [j1-vendor-sc-publish-kit.md](phase-J/j1-vendor-sc-publish-kit.md) | | j.2 | Upstream blockers, secrets, winget/docs | [j2-upstream-blockers-and-docs.md](phase-J/j2-upstream-blockers-and-docs.md) | -| j.3 | Full release rehearsal (kit state machine) | [j3-release-rehearsal.md](phase-J/j3-release-rehearsal.md) | -| j.4 | Production cutover; retire tag-push release | [j4-production-cutover.md](phase-J/j4-production-cutover.md) | +| j.3 | First kit-managed production release | [j3-first-kit-release.md](phase-J/j3-first-kit-release.md) | +| j.4 | Merge integrate/phase-J → develop | [j4-production-cutover.md](phase-J/j4-production-cutover.md) | -See [docs/plans/phase-J/README.md](phase-J/README.md). +See [docs/plans/phase-J/README.md](phase-J/README.md). Tag-push release removed in **j.1** when kit lands on `integrate/phase-J`. --- diff --git a/scripts/sync-sc-publish.sh b/scripts/sync-sc-publish.sh index f4bdc851..3b5a9c2c 100755 --- a/scripts/sync-sc-publish.sh +++ b/scripts/sync-sc-publish.sh @@ -17,11 +17,21 @@ if [[ ! -d "${sc_publish_root}/.git" ]]; then fi SC_PUBLISH_REF="${SC_PUBLISH_REF:-6aace27}" +SC_PUBLISH_EXPECTED_SHA="${SC_PUBLISH_EXPECTED_SHA:-6aace27b78aa6487c9185d831e1ae70f407fded9}" ( cd "${sc_publish_root}" git fetch origin - git checkout "${SC_PUBLISH_REF}" 2>/dev/null || git checkout main && git pull --ff-only origin main + if ! git checkout "${SC_PUBLISH_REF}"; then + echo "sc-publish ref ${SC_PUBLISH_REF} not found" >&2 + exit 1 + fi + actual="$(git rev-parse HEAD)" + expected="$(git rev-parse "${SC_PUBLISH_EXPECTED_SHA}^{commit}" 2>/dev/null || true)" + if [[ -z "${expected}" || "${actual}" != "${expected}" ]]; then + echo "sc-publish HEAD ${actual} != expected ${SC_PUBLISH_EXPECTED_SHA}" >&2 + exit 1 + fi ) if [[ ! -f "${input}" ]]; then From 278bf7671ae3d2d4b98470f471d74e2399fe566f Mon Sep 17 00:00:00 2001 From: Rand Lee Date: Wed, 26 Aug 2026 18:38:30 -0700 Subject: [PATCH 04/12] docs(phase-J): apply quality-mgr plan QA fixes (STEP5-R1). - scripts/sync-sc-publish.sh: run --dry-run before actual install (BASH-F001) - docs/plans/project-plan.md: add Phase J to Dependency Map (PJ-QA-002) - .plan-hardening/guidelines-pass.json: fix stale j3 filename reference (PJ-QA-003) - .plan-hardening/plan-hardening-rounds.md: record STEP5-R1 quality-mgr PASS (PJ-QA-001) Co-authored-by: Cursor --- docs/plans/phase-J/.plan-hardening/guidelines-pass.json | 2 +- docs/plans/phase-J/.plan-hardening/plan-hardening-rounds.md | 2 +- docs/plans/project-plan.md | 3 +++ scripts/sync-sc-publish.sh | 2 +- 4 files changed, 6 insertions(+), 3 deletions(-) diff --git a/docs/plans/phase-J/.plan-hardening/guidelines-pass.json b/docs/plans/phase-J/.plan-hardening/guidelines-pass.json index be9e73d7..683a80f7 100644 --- a/docs/plans/phase-J/.plan-hardening/guidelines-pass.json +++ b/docs/plans/phase-J/.plan-hardening/guidelines-pass.json @@ -10,7 +10,7 @@ "docs/plans/phase-J/README.md", "docs/plans/phase-J/j1-vendor-sc-publish-kit.md", "docs/plans/phase-J/j2-upstream-blockers-and-docs.md", - "docs/plans/phase-J/j3-release-rehearsal.md", + "docs/plans/phase-J/j3-first-kit-release.md", "docs/plans/phase-J/j4-production-cutover.md", "docs/plans/project-plan.md", "docs/WINGET_SETUP.md" diff --git a/docs/plans/phase-J/.plan-hardening/plan-hardening-rounds.md b/docs/plans/phase-J/.plan-hardening/plan-hardening-rounds.md index 1bf4ee92..91fd2e04 100644 --- a/docs/plans/phase-J/.plan-hardening/plan-hardening-rounds.md +++ b/docs/plans/phase-J/.plan-hardening/plan-hardening-rounds.md @@ -7,4 +7,4 @@ | STEP3-R1 | 4 | critical-plan-reviewer (Grok) | 69e3ecf | FAIL | 3 | 6 | 3 | STEP3-R1 | | | | STEP2-R2 | 2 | plan-scope-reviewer | 6d50753 | PASS | 0 | 0 | 0 | phase-j-r2-clean | STEP2-R1 | | | STEP3-R2 | 4 | critical-plan-reviewer (Grok) | 6d50753 | FAIL | 0 | 1 | 3 | STEP3-R2-001 | STEP3-R1 | kit pin script | -| STEP5-R1 | 5 | arch-ctm (inline) | pending | — | — | — | — | | STEP3-R2 | pin fix applied | +| STEP5-R1 | 5 | quality-mgr (cursor-inline) | af6503c | PASS | 0 | 0 | 0 | qm-step5-r1-af6503c | STEP3-R2 | PLAN-CRIT-001 resolved; dry-run order fixed; dep-map updated | diff --git a/docs/plans/project-plan.md b/docs/plans/project-plan.md index 54117dd3..7a39fc2c 100644 --- a/docs/plans/project-plan.md +++ b/docs/plans/project-plan.md @@ -348,4 +348,7 @@ Phase A ├─ Phase E (persistent host + MCP) └─ Phase H (XHTML report surfaces — after G) └─ Phase I (wizard native path picker — after H, closes #99) + +Phase J (sc-publish migration — tooling only; parallel to product feature chain; + gated on main @ v0.5.0+, not on any feature phase) ``` diff --git a/scripts/sync-sc-publish.sh b/scripts/sync-sc-publish.sh index 3b5a9c2c..9efeb06e 100755 --- a/scripts/sync-sc-publish.sh +++ b/scripts/sync-sc-publish.sh @@ -42,6 +42,6 @@ fi publish_python="$( python3 "${kit}/.github/scripts/bootstrap_sc_compose.py" --venv "${venv}" )" -"${publish_python}" "${kit}/install.py" --input "${input}" "${repo_root}" "${publish_python}" "${kit}/install.py" --dry-run --input "${input}" "${repo_root}" +"${publish_python}" "${kit}/install.py" --input "${input}" "${repo_root}" echo "sc-publish kit synced from ${sc_publish_root} @ $(git -C "${sc_publish_root}" rev-parse --short HEAD) (pin ${SC_PUBLISH_REF})" From f773670dfa48b785db6e666ef9bb6a8d3053fc7f Mon Sep 17 00:00:00 2001 From: Rand Lee Date: Wed, 26 Aug 2026 21:52:32 -0700 Subject: [PATCH 05/12] docs(phase-J): add Scoop channel and exclude PyPI from publish plan. Declare homebrew, scoop, and winget in install.json; document PyPI omission until Python bindings exist; extend j2/j3 acceptance criteria for Scoop secrets. Co-authored-by: Cursor --- .../phase-J/.plan-hardening/crit-r2.json | 21 +++++++++++ .../phase-J/.plan-hardening/scope-r2.json | 12 +++++++ docs/plans/phase-J/README.md | 11 ++++-- .../plans/phase-J/j1-vendor-sc-publish-kit.md | 11 ++++-- .../phase-J/j2-upstream-blockers-and-docs.md | 18 ++++++---- docs/plans/phase-J/j3-first-kit-release.md | 13 ++++--- .../phase-J/publish-architecture-decision.md | 4 +++ docs/plans/project-plan.md | 4 +-- release/homebrew/formula.rb.j2 | 36 +++++++++++++++++++ release/install.json | 10 ++++++ release/scoop/manifest.json.j2 | 14 ++++++++ 11 files changed, 136 insertions(+), 18 deletions(-) create mode 100644 docs/plans/phase-J/.plan-hardening/crit-r2.json create mode 100644 docs/plans/phase-J/.plan-hardening/scope-r2.json create mode 100644 release/homebrew/formula.rb.j2 create mode 100644 release/scoop/manifest.json.j2 diff --git a/docs/plans/phase-J/.plan-hardening/crit-r2.json b/docs/plans/phase-J/.plan-hardening/crit-r2.json new file mode 100644 index 00000000..40b72f5d --- /dev/null +++ b/docs/plans/phase-J/.plan-hardening/crit-r2.json @@ -0,0 +1,21 @@ +{ + "status": "FAIL", + "mode": "critical-plan-review", + "reviewer": "critical-plan-reviewer", + "model": "grok", + "round_id": "STEP3-R2", + "round_index": 2, + "reviewed_commit": "6d50753", + "previous_reviewed_commit": "69e3ecf", + "findings_hash": "STEP3-R2-001", + "finding_count": { "blocking": 0, "important": 1, "minor": 3 }, + "supersedes": "STEP3-R1", + "findings": [ + { + "id": "PLAN-CRIT-001", + "severity": "Important", + "summary": "sync-sc-publish.sh fetched and fast-forwarded to main after pin, defeating SHA gate", + "remediation": "Checkout pin only; verify SC_PUBLISH_EXPECTED_SHA; no pull to main" + } + ] +} diff --git a/docs/plans/phase-J/.plan-hardening/scope-r2.json b/docs/plans/phase-J/.plan-hardening/scope-r2.json new file mode 100644 index 00000000..0d11bf85 --- /dev/null +++ b/docs/plans/phase-J/.plan-hardening/scope-r2.json @@ -0,0 +1,12 @@ +{ + "status": "PASS", + "mode": "plan-scope-review", + "reviewer": "plan-scope-reviewer", + "round_id": "STEP2-R2", + "round_index": 2, + "reviewed_commit": "6d50753", + "previous_reviewed_commit": "69e3ecf", + "findings_hash": "phase-j-r2-clean", + "finding_count": { "blocking": 0, "important": 0, "minor": 0 }, + "supersedes": "STEP2-R1" +} diff --git a/docs/plans/phase-J/README.md b/docs/plans/phase-J/README.md index 10817e37..06470a37 100644 --- a/docs/plans/phase-J/README.md +++ b/docs/plans/phase-J/README.md @@ -30,7 +30,7 @@ Wyvern today ships with a **one-off** publish surface: | Manifest | Hand-edited `release/publish-artifacts.toml` | Rendered from caller-owned `release/install.json` | | Agents | Single bespoke `publisher.md` | Kit publisher + channel playbooks (ATM + Cursor inline) | | Winget | Inline step; `GITHUB_TOKEN`; asset `wyvern-windows.zip` | `winget-publish.yml`; **`WINGET_GITHUB_TOKEN`**; manifest asset pattern | -| Retry | `release-retry-distribution.yml` | `crates-publish.yml`, `homebrew-publish.yml`, `winget-publish.yml`, … | +| Retry | `release-retry-distribution.yml` | `crates-publish.yml`, `homebrew-publish.yml`, `scoop-publish.yml`, `winget-publish.yml`, … | This drift blocks **consistent publish ops** across Rust repos and is why v0.5.0 winget failed (no bootstrap + wrong token model). @@ -43,7 +43,12 @@ winget failed (no bootstrap + wrong token model). 2. Retire bespoke publish workflows/agents without forking kit YAML. 3. Adopt the kit **release state machine** (candidate tag → `main` → dispatch). 4. Cut the **first kit-managed production release** with all declared channels - verified (crates.io, GitHub Release assets, Homebrew, winget post-dispatch). + verified (crates.io, GitHub Release assets, Homebrew, Scoop, winget post-dispatch). + +**Supported distribution channels (Wyvern):** crates.io, GitHub Releases, Homebrew, +Scoop, winget. **PyPI is not declared** — wyvern has no Python bindings yet; kit +PyPI legs remain vendored for upstream parity but are inactive when `install.json` +omits `channels.pypi` and leaves `python_packages` / `python_distributions` empty. --- @@ -179,7 +184,9 @@ python3 .github/scripts/release_artifacts.py validate-manifest \ curl -fsS -A wyvern-check "https://crates.io/api/v1/crates/wyvern-cli/X.Y.Z" gh release view vX.Y.Z --json assets curl -fsS "https://raw.githubusercontent.com/randlee/homebrew-tap/main/Formula/wyvern.rb" | rg version +curl -fsS "https://raw.githubusercontent.com/randlee/scoop-bucket/main/bucket/wyvern.json" | rg version gh workflow run winget-publish.yml -f tag=vX.Y.Z --ref main +gh workflow run scoop-publish.yml -f tag=vX.Y.Z --ref main ``` --- diff --git a/docs/plans/phase-J/j1-vendor-sc-publish-kit.md b/docs/plans/phase-J/j1-vendor-sc-publish-kit.md index 9f6a634e..585204c7 100644 --- a/docs/plans/phase-J/j1-vendor-sc-publish-kit.md +++ b/docs/plans/phase-J/j1-vendor-sc-publish-kit.md @@ -31,6 +31,7 @@ entrypoint. Remove legacy bespoke publish files. | `.github/workflows/release-candidate.yml` | Kit candidate tag (vendored) | | `.github/workflows/crates-publish.yml` | Per-channel crates retry (vendored) | | `.github/workflows/homebrew-publish.yml` | Per-channel Homebrew retry (vendored) | +| `.github/workflows/scoop-publish.yml` | Per-channel Scoop retry (vendored) | | `.github/workflows/winget-publish.yml` | Per-channel winget retry (vendored) | | `.github/scripts/release_artifacts.py` | Manifest CLI (vendored) | | `.github/scripts/release_gate.sh` | Release gate (vendored) | @@ -64,19 +65,25 @@ entrypoint. Remove legacy bespoke publish files. "workspace_toml": "Cargo.toml", "rust_toolchain": "stable" }, + "python_packages": [], + "python_distributions": [], "channels": { "homebrew": { "tap_repository": "randlee/homebrew-tap", "...": "..." }, - "winget": { "identifier": "randlee.wyvern", "installer_target": "x86_64-pc-windows-msvc" } + "winget": { "identifier": "randlee.wyvern", "installer_target": "x86_64-pc-windows-msvc" }, + "scoop": { "bucket_repository": "randlee/scoop-bucket", "manifest_path": "bucket/wyvern.json", "...": "..." } } } ``` +Wyvern declares **no PyPI channel** (`python_packages` / `python_distributions` +empty; no `channels.pypi`) until Python bindings exist. + Full file is authoritative at `release/install.json`. ## Acceptance criteria 1. `./scripts/sync-sc-publish.sh` checks out pinned `SC_PUBLISH_REF` (default `6aace27`) at exact SHA `SC_PUBLISH_EXPECTED_SHA` (default `6aace27a…`); no pull to `main` after checkout; dry-run exit **0**. -2. `release/install.json` declares five publishable crates (orders 1–5), `wyvern-mcp` unpublished, four release targets, binaries `wyvern` + `wyvern-viewer`, bundled `ui/`, channels **homebrew** + **winget** only. +2. `release/install.json` declares five publishable crates (orders 1–5), `wyvern-mcp` unpublished, four release targets, binaries `wyvern` + `wyvern-viewer`, bundled `ui/`, channels **homebrew**, **scoop**, and **winget**; **no** `channels.pypi` and empty Python package lists. 3. Every deliverable path in the table above exists after sync (kit byte-for-byte copies). 4. Every path in **Paths to delete** is absent from the branch. 5. `python3 .github/scripts/release_artifacts.py validate-manifest --manifest release/publish-artifacts.toml --workspace-toml Cargo.toml` exits **0**. diff --git a/docs/plans/phase-J/j2-upstream-blockers-and-docs.md b/docs/plans/phase-J/j2-upstream-blockers-and-docs.md index 1d27f196..abbaa86b 100644 --- a/docs/plans/phase-J/j2-upstream-blockers-and-docs.md +++ b/docs/plans/phase-J/j2-upstream-blockers-and-docs.md @@ -24,7 +24,8 @@ Resolve CR-001 and CR-002 **before** j.3. Update consumer docs and provision | Path | Purpose | |------|---------| | `release/install.json` | Homebrew UI path; renderer field updated when CR-002 resolved | -| `docs/RELEASE_SECRETS.md` | Kit secrets incl. `WINGET_GITHUB_TOKEN` + PAT scope note | +| `docs/RELEASE_SECRETS.md` | Kit secrets incl. `WINGET_GITHUB_TOKEN`, `SCOOP_BUCKET_TOKEN` + PAT scope notes | +| `docs/SCOOP_SETUP.md` | Scoop bucket repo, manifest leg, token model | | `docs/WINGET_SETUP.md` | sc-publish winget leg, bootstrap, token model | | `README.md` | Kit archive naming | | `docs/plans/phase-J/publish-architecture-decision.md` | ADR-linked decision record | @@ -41,12 +42,14 @@ Resolve CR-001 and CR-002 **before** j.3. Update consumer docs and provision 1. `release/install.json` sets `homebrew_destination_components` to `["share","wyvern","ui"]`; re-sync dry-run exit **0**. 2. CR-001 **resolved**: sc-publish PR merged; Linux webview apt packages run in kit release/preflight/crates jobs — link in upstream-tracking. 3. CR-002 **resolved**: sc-publish PR merged; Homebrew leg bootstraps sc-compose on runner; `release/install.json` **does not** use product binary as renderer (`renderer_archive_path` removed or names kit renderer path per upstream contract). -4. `docs/RELEASE_SECRETS.md` documents `CARGO_REGISTRY_TOKEN`, `HOMEBREW_TAP_TOKEN`, `WINGET_GITHUB_TOKEN` (classic or fine-grained PAT; minimum: fork `microsoft/winget-pkgs`, open PRs). +4. `docs/RELEASE_SECRETS.md` documents `CARGO_REGISTRY_TOKEN`, `HOMEBREW_TAP_TOKEN`, `WINGET_GITHUB_TOKEN` (classic or fine-grained PAT; minimum: fork `microsoft/winget-pkgs`, open PRs), and `SCOOP_BUCKET_TOKEN` (push to `randlee/scoop-bucket`). 5. `docs/WINGET_SETUP.md` matches kit: post-release dispatch, token, asset pattern, bootstrap requirement, review lag. -6. `README.md` artifact table uses `wyvern__.*` and `bin/` layout. -7. `gh secret list` includes **`WINGET_GITHUB_TOKEN`** (j.2 **cannot** close without it). -8. If `randlee.wyvern` absent from `winget-pkgs`, owner completes one-time bootstrap **before** j.3 (document completion in closeout). -9. upstream-tracking shows CR-001 and CR-002 both **`resolved`** (not waived). +6. `docs/SCOOP_SETUP.md` documents bucket layout (`bucket/wyvern.json`), post-release `scoop-publish.yml` dispatch, and `SCOOP_BUCKET_TOKEN`. +7. `README.md` artifact table uses `wyvern__.*` and `bin/` layout. +8. `gh secret list` includes **`WINGET_GITHUB_TOKEN`** and **`SCOOP_BUCKET_TOKEN`** (j.2 **cannot** close without them). +9. If `randlee.wyvern` absent from `winget-pkgs`, owner completes one-time bootstrap **before** j.3 (document completion in closeout). +10. If `randlee/scoop-bucket` absent or empty, owner creates bucket repo and initial manifest **before** j.3 (document in closeout). +11. upstream-tracking shows CR-001 and CR-002 both **`resolved`** (not waived). ## Non-closure (explicit) @@ -59,7 +62,8 @@ Resolve CR-001 and CR-002 **before** j.3. Update consumer docs and provision ./scripts/sync-sc-publish.sh python3 .github/scripts/release_artifacts.py validate-manifest \ --manifest release/publish-artifacts.toml --workspace-toml Cargo.toml -gh secret list | rg 'WINGET_GITHUB_TOKEN' +gh secret list | rg 'WINGET_GITHUB_TOKEN|SCOOP_BUCKET_TOKEN' test ! -f scripts/validate_release.py rg 'WINGET_GITHUB_TOKEN' docs/RELEASE_SECRETS.md docs/WINGET_SETUP.md +rg 'SCOOP_BUCKET_TOKEN' docs/RELEASE_SECRETS.md docs/SCOOP_SETUP.md ``` diff --git a/docs/plans/phase-J/j3-first-kit-release.md b/docs/plans/phase-J/j3-first-kit-release.md index 38aa0881..2fef8865 100644 --- a/docs/plans/phase-J/j3-first-kit-release.md +++ b/docs/plans/phase-J/j3-first-kit-release.md @@ -19,8 +19,9 @@ Cut the **first production semver** using the sc-publish state machine on ## Hard dependencies - j.2 closeout: CR-001 and CR-002 **resolved** (upstream-tracking) -- `WINGET_GITHUB_TOKEN` provisioned (j.2 AC #7) -- Winget bootstrap complete if first package (j.2 AC #8) +- `WINGET_GITHUB_TOKEN` and `SCOOP_BUCKET_TOKEN` provisioned (j.2 AC #8) +- Winget bootstrap complete if first package (j.2 AC #9) +- Scoop bucket repo ready if first manifest (j.2 AC #10) - GitHub environment `crates-io` exists ## Deliverables @@ -38,9 +39,10 @@ Cut the **first production semver** using the sc-publish state machine on 5. Archives contain `bin/wyvern`, `bin/wyvern-viewer`, `share/wyvern/ui/{message,input,markdown,question,chrome}/index.html`. 6. crates.io publish succeeds or detect-and-skips. 7. If `install.json` declares `homebrew`: `homebrew-publish.yml` succeeds. If channel absent, leg not dispatched (document skip). -8. `winget-publish.yml` opens submission PR (skip-probe allowed **only** when version already submitted); auth failure = fail. -9. Re-dispatch one post-release leg; detect-and-skip works. -10. `first-release-record.md` documents channels tested/skipped and go/no-go for j.4. +8. If `install.json` declares `scoop`: `scoop-publish.yml` succeeds and updates `randlee/scoop-bucket` manifest. If channel absent, leg not dispatched (document skip). +9. `winget-publish.yml` opens submission PR (skip-probe allowed **only** when version already submitted); auth failure = fail. +10. Re-dispatch one post-release leg; detect-and-skip works. +11. `first-release-record.md` documents channels tested/skipped and go/no-go for j.4. ## Non-closure (explicit) @@ -53,4 +55,5 @@ Cut the **first production semver** using the sc-publish state machine on gh release view vX.Y.Z --json assets curl -fsS -A wyvern-check "https://crates.io/api/v1/crates/wyvern-cli/X.Y.Z" gh run list --workflow winget-publish.yml --limit 3 +gh run list --workflow scoop-publish.yml --limit 3 ``` diff --git a/docs/plans/phase-J/publish-architecture-decision.md b/docs/plans/phase-J/publish-architecture-decision.md index 0a0315c3..a73928ec 100644 --- a/docs/plans/phase-J/publish-architecture-decision.md +++ b/docs/plans/phase-J/publish-architecture-decision.md @@ -28,6 +28,10 @@ other Rust repos. used for winget submit. 6. **Product gates:** `ci.yml` retains wyvern-only checks (boundaries, ui-sync, share-sync). Kit preflight does not replace them. +7. **Distribution channels:** Wyvern publishes to crates.io, GitHub Releases, + Homebrew, Scoop, and winget. **PyPI is excluded** until Python bindings exist; + `install.json` omits `channels.pypi` and leaves Python package lists empty so + rendered manifests and preflight skip PyPI credential checks. ## Consequences diff --git a/docs/plans/project-plan.md b/docs/plans/project-plan.md index 7a39fc2c..54847bff 100644 --- a/docs/plans/project-plan.md +++ b/docs/plans/project-plan.md @@ -304,7 +304,7 @@ See [docs/plans/phase-I/README.md](phase-I/README.md). **Phase goal:** Replace bespoke publish workflows/agents with the shared **[`sc-publish`](https://github.com/randlee/sc-publish)** kit for consistency across Rust repos — manifest-driven releases, per-channel retries, and fixed -winget/Homebrew semantics. +winget/Homebrew/Scoop semantics. **Prerequisite:** Current release line on `main` (v0.5.0+). Tooling-only phase. @@ -334,7 +334,7 @@ See [docs/plans/phase-J/README.md](phase-J/README.md). Tag-push release removed | Phase E — Interactive & MCP | 4 | Agent-driveable status viewer + MCP | | Phase H — XHTML reporting & review | 5 | Single/array XHTML panels + `--review` finish JSON | | Phase I — Wizard native path picker | 1 | Wizard in-page native pickers + `path-picker` example (closes #99) | -| Phase J — sc-publish migration | 4 | Kit vendored publish; winget/Homebrew/crates retry legs; tag-push retired | +| Phase J — sc-publish migration | 4 | Kit vendored publish; crates/GitHub/Homebrew/Scoop/winget legs; PyPI omitted (no Python bindings); tag-push retired | ## Dependency Map diff --git a/release/homebrew/formula.rb.j2 b/release/homebrew/formula.rb.j2 new file mode 100644 index 00000000..ebbd17e0 --- /dev/null +++ b/release/homebrew/formula.rb.j2 @@ -0,0 +1,36 @@ +class {{ formula_class }} < Formula + desc {{ description | tojson }} + homepage {{ homepage | tojson }} + version {{ version | tojson }} + license {{ license | tojson }} + + on_macos do + on_arm do + url {{ macos_arm_url | tojson }} + sha256 {{ macos_arm_sha256 | tojson }} + end + + on_intel do + url {{ macos_intel_url | tojson }} + sha256 {{ macos_intel_sha256 | tojson }} + end + end + + on_linux do + url {{ linux_url | tojson }} + sha256 {{ linux_sha256 | tojson }} + end + + def install +{% for binary_path in binary_paths %} + bin.install {{ binary_path | tojson }} +{% endfor %} +{% for bundle in bundled_paths %} + ({% for component in bundle.destination_components %}{{ component | tojson }}{% if not loop.last %}/{% endif %}{% endfor %}).install Dir[{{ bundle.source_glob | tojson }}] +{% endfor %} + end + + test do + assert_match {{ test_output | tojson }}, shell_output("#{bin}/" + {{ test_binary | tojson }} + " " + {{ test_command | tojson }}) + end +end diff --git a/release/install.json b/release/install.json index 45b3eb66..c540dd51 100644 --- a/release/install.json +++ b/release/install.json @@ -129,6 +129,16 @@ "dispatch_inputs": {}, "identifier": "randlee.wyvern", "installer_target": "x86_64-pc-windows-msvc" + }, + "scoop": { + "workflow": "scoop-publish.yml", + "dispatch_inputs": {}, + "bucket_repository": "randlee/scoop-bucket", + "manifest_path": "bucket/wyvern.json", + "manifest_template": "release/scoop/manifest.json.j2", + "installer_target": "x86_64-pc-windows-msvc", + "binary": "bin/wyvern.exe", + "renderer_target": "x86_64-unknown-linux-gnu" } } } diff --git a/release/scoop/manifest.json.j2 b/release/scoop/manifest.json.j2 new file mode 100644 index 00000000..33eb0b91 --- /dev/null +++ b/release/scoop/manifest.json.j2 @@ -0,0 +1,14 @@ +{ + "version": {{ version | tojson }}, + "description": {{ description | tojson }}, + "homepage": {{ homepage | tojson }}, + "license": {{ license | tojson }}, + "architecture": { + "64bit": { + "url": {{ windows_url | tojson }}, + "hash": {{ windows_sha256 | tojson }}, + "extract_dir": {{ extract_dir | tojson }}, + "bin": {{ binary | tojson }} + } + } +} From 5f6b508d03c7a961157998e71cdfa48224f131cf Mon Sep 17 00:00:00 2001 From: Rand Lee Date: Wed, 26 Aug 2026 21:54:09 -0700 Subject: [PATCH 06/12] docs(phase-J): address plan-scope R3 findings (Scoop index, PyPI skip AC). Align j.2 index summaries with Scoop deliverables; add j.3 PyPI N/A criterion, j.1 inactive PyPI note, and j.4 Scoop validation probe. Co-authored-by: Cursor --- .../.plan-hardening/plan-hardening-rounds.md | 2 ++ docs/plans/phase-J/.plan-hardening/scope-r3.json | 16 ++++++++++++++++ docs/plans/phase-J/README.md | 2 +- docs/plans/phase-J/j1-vendor-sc-publish-kit.md | 1 + docs/plans/phase-J/j3-first-kit-release.md | 6 ++++-- docs/plans/phase-J/j4-production-cutover.md | 1 + docs/plans/project-plan.md | 2 +- 7 files changed, 26 insertions(+), 4 deletions(-) create mode 100644 docs/plans/phase-J/.plan-hardening/scope-r3.json diff --git a/docs/plans/phase-J/.plan-hardening/plan-hardening-rounds.md b/docs/plans/phase-J/.plan-hardening/plan-hardening-rounds.md index 91fd2e04..45b72d46 100644 --- a/docs/plans/phase-J/.plan-hardening/plan-hardening-rounds.md +++ b/docs/plans/phase-J/.plan-hardening/plan-hardening-rounds.md @@ -8,3 +8,5 @@ | STEP2-R2 | 2 | plan-scope-reviewer | 6d50753 | PASS | 0 | 0 | 0 | phase-j-r2-clean | STEP2-R1 | | | STEP3-R2 | 4 | critical-plan-reviewer (Grok) | 6d50753 | FAIL | 0 | 1 | 3 | STEP3-R2-001 | STEP3-R1 | kit pin script | | STEP5-R1 | 5 | quality-mgr (cursor-inline) | af6503c | PASS | 0 | 0 | 0 | qm-step5-r1-af6503c | STEP3-R2 | PLAN-CRIT-001 resolved; dry-run order fixed; dep-map updated | +| STEP2-R3 | 2 | plan-scope-reviewer | f773670 | FAIL | 0 | 2 | 1 | phase-j-r3-scoop-pypi-index | STEP2-R2 | Scoop index + PyPI skip AC | +| STEP2-R3-fix | 2 | plan-scope-reviewer | pending | — | — | — | — | | STEP2-R3 | scope fixes applied | diff --git a/docs/plans/phase-J/.plan-hardening/scope-r3.json b/docs/plans/phase-J/.plan-hardening/scope-r3.json new file mode 100644 index 00000000..fefe85ed --- /dev/null +++ b/docs/plans/phase-J/.plan-hardening/scope-r3.json @@ -0,0 +1,16 @@ +{ + "status": "FAIL", + "mode": "plan-scope-review", + "reviewer": "plan-scope-reviewer", + "round_id": "STEP2-R3", + "round_index": 3, + "reviewed_commit": "f773670", + "previous_reviewed_commit": "6d50753", + "findings_hash": "phase-j-r3-scoop-pypi-index", + "finding_count": { "blocking": 0, "important": 2, "minor": 1 }, + "findings": [ + { "id": "PLAN-SCOPE-001", "severity": "Important", "summary": "j.2 index summaries omit Scoop" }, + { "id": "PLAN-SCOPE-002", "severity": "Important", "summary": "j.3 missing PyPI N/A/skipped AC" }, + { "id": "PLAN-SCOPE-M1", "severity": "Minor", "summary": "j.4 missing Scoop validation probe" } + ] +} diff --git a/docs/plans/phase-J/README.md b/docs/plans/phase-J/README.md index 06470a37..d2237c87 100644 --- a/docs/plans/phase-J/README.md +++ b/docs/plans/phase-J/README.md @@ -90,7 +90,7 @@ required validation. This README is the phase index only. | Sprint | Ships | Doc | |--------|-------|-----| | **j.1** | Vendor kit, `install.json`, sync script | [j1-vendor-sc-publish-kit.md](j1-vendor-sc-publish-kit.md) | -| **j.2** | Upstream blockers, secrets, winget/docs | [j2-upstream-blockers-and-docs.md](j2-upstream-blockers-and-docs.md) | +| **j.2** | Upstream blockers, secrets, Scoop/winget docs | [j2-upstream-blockers-and-docs.md](j2-upstream-blockers-and-docs.md) | | **j.3** | First kit-managed release (`vX.Y.Z`) | [j3-first-kit-release.md](j3-first-kit-release.md) | | **j.4** | Merge `integrate/phase-J` → `develop` | [j4-production-cutover.md](j4-production-cutover.md) | diff --git a/docs/plans/phase-J/j1-vendor-sc-publish-kit.md b/docs/plans/phase-J/j1-vendor-sc-publish-kit.md index 585204c7..b8e687a4 100644 --- a/docs/plans/phase-J/j1-vendor-sc-publish-kit.md +++ b/docs/plans/phase-J/j1-vendor-sc-publish-kit.md @@ -94,6 +94,7 @@ Full file is authoritative at `release/install.json`. - **j.1 removes tag-push release** when merged to `integrate/phase-J` — kit `release.yml` replaces legacy trigger (see [publish-architecture-decision.md](publish-architecture-decision.md)). - **j.1 does not** resolve CR-001/CR-002 or provision secrets — **j.2**. - **j.1 does not** cut a release — **j.3**. +- Vendored kit may include inactive **PyPI** workflows/agents for upstream parity; wyvern channel scope excludes PyPI until `install.json` declares `channels.pypi`. ## Required validation diff --git a/docs/plans/phase-J/j3-first-kit-release.md b/docs/plans/phase-J/j3-first-kit-release.md index 2fef8865..386dd3eb 100644 --- a/docs/plans/phase-J/j3-first-kit-release.md +++ b/docs/plans/phase-J/j3-first-kit-release.md @@ -41,8 +41,9 @@ Cut the **first production semver** using the sc-publish state machine on 7. If `install.json` declares `homebrew`: `homebrew-publish.yml` succeeds. If channel absent, leg not dispatched (document skip). 8. If `install.json` declares `scoop`: `scoop-publish.yml` succeeds and updates `randlee/scoop-bucket` manifest. If channel absent, leg not dispatched (document skip). 9. `winget-publish.yml` opens submission PR (skip-probe allowed **only** when version already submitted); auth failure = fail. -10. Re-dispatch one post-release leg; detect-and-skip works. -11. `first-release-record.md` documents channels tested/skipped and go/no-go for j.4. +10. When `install.json` omits `channels.pypi`: `pypi-publish.yml` is **not** dispatched during the release; record PyPI as **N/A/skipped** in `first-release-record.md` (kit may vendor inactive PyPI assets — out of wyvern channel scope). +11. Re-dispatch one post-release leg; detect-and-skip works. +12. `first-release-record.md` documents channels tested/skipped (including PyPI N/A when omitted) and go/no-go for j.4. ## Non-closure (explicit) @@ -56,4 +57,5 @@ gh release view vX.Y.Z --json assets curl -fsS -A wyvern-check "https://crates.io/api/v1/crates/wyvern-cli/X.Y.Z" gh run list --workflow winget-publish.yml --limit 3 gh run list --workflow scoop-publish.yml --limit 3 +! gh run list --workflow pypi-publish.yml --limit 3 | rg 'vX\.Y\.Z' || true ``` diff --git a/docs/plans/phase-J/j4-production-cutover.md b/docs/plans/phase-J/j4-production-cutover.md index cc6de87b..03bcc569 100644 --- a/docs/plans/phase-J/j4-production-cutover.md +++ b/docs/plans/phase-J/j4-production-cutover.md @@ -50,3 +50,4 @@ None — j.1 removed legacy publish files; kit `release.yml` has no tag-push tri ``` If Homebrew channel active: `brew info wyvern` shows j.3 version. +If Scoop channel active: `curl -fsS "https://raw.githubusercontent.com/randlee/scoop-bucket/main/bucket/wyvern.json" | rg '"version"'` matches j.3 version. diff --git a/docs/plans/project-plan.md b/docs/plans/project-plan.md index 54847bff..b90d9f3b 100644 --- a/docs/plans/project-plan.md +++ b/docs/plans/project-plan.md @@ -314,7 +314,7 @@ Phase J sprint PRs target `integrate/phase-J`. Sprint authority: | Sprint | Deliverable | Doc | |--------|-------------|-----| | j.1 | Vendor kit + `install.json` + sync script | [j1-vendor-sc-publish-kit.md](phase-J/j1-vendor-sc-publish-kit.md) | -| j.2 | Upstream blockers, secrets, winget/docs | [j2-upstream-blockers-and-docs.md](phase-J/j2-upstream-blockers-and-docs.md) | +| j.2 | Upstream blockers, secrets, Scoop/winget docs | [j2-upstream-blockers-and-docs.md](phase-J/j2-upstream-blockers-and-docs.md) | | j.3 | First kit-managed production release | [j3-first-kit-release.md](phase-J/j3-first-kit-release.md) | | j.4 | Merge integrate/phase-J → develop | [j4-production-cutover.md](phase-J/j4-production-cutover.md) | From 3aeba7b2bcf1581e1f02f14ead01a7e850ddb114 Mon Sep 17 00:00:00 2001 From: Rand Lee Date: Wed, 26 Aug 2026 22:00:38 -0700 Subject: [PATCH 07/12] docs(phase-J): address critical-plan R3 findings (channels, tokens, PyPI). Make Homebrew/Scoop unconditional j.3 gates; expand CR-002 for both renderer consumers; clarify contracts vs artifacts PyPI filtering; add SCOOP_BUCKET_TOKEN ADR; tighten Scoop bootstrap and PyPI dispatch validation. Co-authored-by: Cursor --- .../phase-J/.plan-hardening/crit-r3.json | 11 ++++++++ .../.plan-hardening/plan-hardening-rounds.md | 4 ++- .../.plan-hardening/upstream-tracking.md | 2 +- docs/plans/phase-J/README.md | 13 ++++++---- .../plans/phase-J/j1-vendor-sc-publish-kit.md | 4 +-- .../phase-J/j2-upstream-blockers-and-docs.md | 25 ++++++++++++++----- docs/plans/phase-J/j3-first-kit-release.md | 14 +++++++---- docs/plans/phase-J/j4-production-cutover.md | 5 ++-- .../phase-J/publish-architecture-decision.md | 22 ++++++++++------ 9 files changed, 69 insertions(+), 31 deletions(-) create mode 100644 docs/plans/phase-J/.plan-hardening/crit-r3.json diff --git a/docs/plans/phase-J/.plan-hardening/crit-r3.json b/docs/plans/phase-J/.plan-hardening/crit-r3.json new file mode 100644 index 00000000..269c2617 --- /dev/null +++ b/docs/plans/phase-J/.plan-hardening/crit-r3.json @@ -0,0 +1,11 @@ +{ + "status": "FAIL", + "mode": "critical-plan-review", + "reviewer": "critical-plan-reviewer", + "model": "grok", + "round_id": "STEP3-R3", + "round_index": 3, + "reviewed_commit": "5f6b508", + "finding_count": { "blocking": 0, "important": 6, "minor": 2 }, + "findings_hash": "STEP3-R3-wt-001:006" +} diff --git a/docs/plans/phase-J/.plan-hardening/plan-hardening-rounds.md b/docs/plans/phase-J/.plan-hardening/plan-hardening-rounds.md index 45b72d46..7b6826b8 100644 --- a/docs/plans/phase-J/.plan-hardening/plan-hardening-rounds.md +++ b/docs/plans/phase-J/.plan-hardening/plan-hardening-rounds.md @@ -9,4 +9,6 @@ | STEP3-R2 | 4 | critical-plan-reviewer (Grok) | 6d50753 | FAIL | 0 | 1 | 3 | STEP3-R2-001 | STEP3-R1 | kit pin script | | STEP5-R1 | 5 | quality-mgr (cursor-inline) | af6503c | PASS | 0 | 0 | 0 | qm-step5-r1-af6503c | STEP3-R2 | PLAN-CRIT-001 resolved; dry-run order fixed; dep-map updated | | STEP2-R3 | 2 | plan-scope-reviewer | f773670 | FAIL | 0 | 2 | 1 | phase-j-r3-scoop-pypi-index | STEP2-R2 | Scoop index + PyPI skip AC | -| STEP2-R3-fix | 2 | plan-scope-reviewer | pending | — | — | — | — | | STEP2-R3 | scope fixes applied | +| STEP2-R3-fix | 2 | plan-scope-reviewer | 5f6b508 | PASS | 0 | 0 | 0 | | STEP2-R3 | scope fixes applied | +| STEP3-R3 | 4 | critical-plan-reviewer (Grok) | 5f6b508 | FAIL | 0 | 6 | 2 | STEP3-R3-001 | STEP3-R2 | Scoop/Homebrew/PyPI contracts | +| STEP3-R3-fix | 4 | critical-plan-reviewer (Grok) | pending | — | — | — | — | | STEP3-R3 | crit fixes applied | diff --git a/docs/plans/phase-J/.plan-hardening/upstream-tracking.md b/docs/plans/phase-J/.plan-hardening/upstream-tracking.md index 4646afde..2608043b 100644 --- a/docs/plans/phase-J/.plan-hardening/upstream-tracking.md +++ b/docs/plans/phase-J/.plan-hardening/upstream-tracking.md @@ -5,7 +5,7 @@ Each blocker is exactly one state: | Blocker | State | PR link | Signed waiver | Effect | |---------|-------|---------|---------------|--------| | CR-001 Linux webview deps | open | | | j.3/j.4 **not startable** until resolved | -| CR-002 Homebrew renderer | open | | | j.3/j.4 **not startable** until resolved | +| CR-002 Homebrew/Scoop renderer | open | | | j.3/j.4 **not startable** until resolved | **Rules:** diff --git a/docs/plans/phase-J/README.md b/docs/plans/phase-J/README.md index d2237c87..aba4777f 100644 --- a/docs/plans/phase-J/README.md +++ b/docs/plans/phase-J/README.md @@ -47,8 +47,9 @@ winget failed (no bootstrap + wrong token model). **Supported distribution channels (Wyvern):** crates.io, GitHub Releases, Homebrew, Scoop, winget. **PyPI is not declared** — wyvern has no Python bindings yet; kit -PyPI legs remain vendored for upstream parity but are inactive when `install.json` -omits `channels.pypi` and leaves `python_packages` / `python_distributions` empty. +PyPI legs remain vendored for upstream parity but are **inactive** when +`install.json` omits `channels.pypi` (no `[channels.pypi]` in +`publish-artifacts.toml`; preflight skips PyPI secrets/environments). --- @@ -66,8 +67,8 @@ Source of truth: sibling repo `../sc-publish` (`plugins/sc-publish`). | Asset | Role | |-------|------| | `release/install.json` | **Only** hand-maintained publish contract (crates, targets, channels) | -| `release/publish-artifacts.toml` | Rendered — repo-specific artifacts & destinations | -| `release/publish-channel-contracts.toml` | Rendered (channel-filtered) | +| `release/publish-artifacts.toml` | Rendered from `install.json` — **wyvern channel set** (no PyPI section when omitted) | +| `release/publish-channel-contracts.toml` | Vendored kit protocol (full channel table incl. PyPI reference); preflight filters checks via `publish-artifacts.toml` | | `.github/workflows/release*.yml` | Root + preflight + candidate | | `.github/workflows/*-publish.yml` | Post-release per-channel retries | | `.claude/agents/*-publisher.md` | ATM channel workers + coordinator | @@ -129,11 +130,12 @@ Findings below are **in scope** for Phase J sprints. | ID | Finding | Plan response | Sprint | |----|---------|---------------|--------| | CR-001 | Linux webview deps missing from kit jobs | **Must resolve** upstream before j.3; waiver blocks phase | j.2 | -| CR-002 | Homebrew renderer uses product binary | **Must resolve** upstream; update `install.json` renderer field | j.2 | +| CR-002 | Homebrew/Scoop renderer uses product binary | **Must resolve** upstream for **both** `homebrew-publish.yml` and `scoop-publish.yml`; keep valid `renderer_archive_path` in `install.json` | j.2 | | CR-003 | Homebrew UI path flattening | `homebrew_destination_components` → `["share","wyvern","ui"]` | j.2 | | CR-004 | Tag-push vs kit dispatch | Tag-push removed when j.1 lands on `integrate/phase-J` | j.1, [ADR](publish-architecture-decision.md) | | CR-005 | Archive rename | Update README in j.2; release notes in j.3 | j.2, j.3 | | CR-006 | `WINGET_GITHUB_TOKEN` required | Provision in j.2; bootstrap before j.3 if needed | j.2 | +| CR-010 | `SCOOP_BUCKET_TOKEN` required | Provision in j.2; bucket bootstrap before j.3 if needed | j.2 | | CR-007 | Wyvern gates stay in `ci.yml` | j.1 zero-diff on `ci.yml` | j.1 | | CR-008 | Legacy `validate_release.py` | Delete in j.2 | j.2 | | CR-009 | Moving sc-publish head | Pin `SC_PUBLISH_REF` in sync script | j.1 | @@ -197,3 +199,4 @@ gh workflow run scoop-publish.yml -f tag=vX.Y.Z --ref main - [`scripts/sync-sc-publish.sh`](../../../scripts/sync-sc-publish.sh) — vendor entrypoint - [docs/RELEASE_SECRETS.md](../../../docs/RELEASE_SECRETS.md) — updated j.2 - [docs/WINGET_SETUP.md](../../../WINGET_SETUP.md) — updated j.2 +- [docs/SCOOP_SETUP.md](../../../docs/SCOOP_SETUP.md) — updated j.2 diff --git a/docs/plans/phase-J/j1-vendor-sc-publish-kit.md b/docs/plans/phase-J/j1-vendor-sc-publish-kit.md index b8e687a4..96423fbb 100644 --- a/docs/plans/phase-J/j1-vendor-sc-publish-kit.md +++ b/docs/plans/phase-J/j1-vendor-sc-publish-kit.md @@ -37,8 +37,8 @@ entrypoint. Remove legacy bespoke publish files. | `.github/scripts/release_gate.sh` | Release gate (vendored) | | `.claude/agents/publisher.md` + channel publishers | ATM publish profile (vendored) | | `.cursor/agents/publisher.md` + publish skill/command | Cursor inline profile (vendored) | -| `release/publish-artifacts.toml` | Rendered from `install.json` | -| `release/publish-channel-contracts.toml` | Rendered from `install.json` (channel-filtered) | +| `release/publish-artifacts.toml` | Rendered from `install.json` (wyvern channel set) | +| `release/publish-channel-contracts.toml` | Vendored kit protocol (full table; preflight filters via artifacts manifest) | | `README.sc-publish.md` | Kit README (does not replace Wyvern `README.md`) | | `.gitignore` | Entry `.sc-publish-venv/` | diff --git a/docs/plans/phase-J/j2-upstream-blockers-and-docs.md b/docs/plans/phase-J/j2-upstream-blockers-and-docs.md index abbaa86b..92e53f7c 100644 --- a/docs/plans/phase-J/j2-upstream-blockers-and-docs.md +++ b/docs/plans/phase-J/j2-upstream-blockers-and-docs.md @@ -12,8 +12,9 @@ depends_on: j.1 ## Goal -Resolve CR-001 and CR-002 **before** j.3. Update consumer docs and provision -`WINGET_GITHUB_TOKEN`. Waivers **block** j.3/j.4 (see upstream-tracking). +Resolve CR-001 and CR-002 **before** j.3. Update consumer docs; provision +`WINGET_GITHUB_TOKEN` and `SCOOP_BUCKET_TOKEN`; publish `docs/SCOOP_SETUP.md`. +Waivers **block** j.3/j.4 (see upstream-tracking). ## Hard dependencies @@ -23,7 +24,7 @@ Resolve CR-001 and CR-002 **before** j.3. Update consumer docs and provision | Path | Purpose | |------|---------| -| `release/install.json` | Homebrew UI path; renderer field updated when CR-002 resolved | +| `release/install.json` | Homebrew UI path; `renderer_archive_path` names kit renderer path when CR-002 resolved | | `docs/RELEASE_SECRETS.md` | Kit secrets incl. `WINGET_GITHUB_TOKEN`, `SCOOP_BUCKET_TOKEN` + PAT scope notes | | `docs/SCOOP_SETUP.md` | Scoop bucket repo, manifest leg, token model | | `docs/WINGET_SETUP.md` | sc-publish winget leg, bootstrap, token model | @@ -41,14 +42,26 @@ Resolve CR-001 and CR-002 **before** j.3. Update consumer docs and provision 1. `release/install.json` sets `homebrew_destination_components` to `["share","wyvern","ui"]`; re-sync dry-run exit **0**. 2. CR-001 **resolved**: sc-publish PR merged; Linux webview apt packages run in kit release/preflight/crates jobs — link in upstream-tracking. -3. CR-002 **resolved**: sc-publish PR merged; Homebrew leg bootstraps sc-compose on runner; `release/install.json` **does not** use product binary as renderer (`renderer_archive_path` removed or names kit renderer path per upstream contract). +3. CR-002 **resolved**: sc-publish PR merged; **both** `homebrew-publish.yml` and + `scoop-publish.yml` bootstrap sc-compose on the runner (not product binary as + renderer); `release/install.json` keeps a **non-empty** `renderer_archive_path` + naming the kit renderer archive path per upstream contract (must not remove while + Scoop is declared). 4. `docs/RELEASE_SECRETS.md` documents `CARGO_REGISTRY_TOKEN`, `HOMEBREW_TAP_TOKEN`, `WINGET_GITHUB_TOKEN` (classic or fine-grained PAT; minimum: fork `microsoft/winget-pkgs`, open PRs), and `SCOOP_BUCKET_TOKEN` (push to `randlee/scoop-bucket`). 5. `docs/WINGET_SETUP.md` matches kit: post-release dispatch, token, asset pattern, bootstrap requirement, review lag. -6. `docs/SCOOP_SETUP.md` documents bucket layout (`bucket/wyvern.json`), post-release `scoop-publish.yml` dispatch, and `SCOOP_BUCKET_TOKEN`. +6. `docs/SCOOP_SETUP.md` documents: public cloneable `randlee/scoop-bucket`; + `bucket/wyvern.json` layout; post-release `scoop-publish.yml` dispatch; + **`SCOOP_BUCKET_TOKEN`** (PAT with `contents:write` on that repo — **not** + repository `GITHUB_TOKEN`); Windows asset name + `wyvern__x86_64-pc-windows-msvc.zip`; first-run may create manifest + via workflow (empty bucket acceptable if doc states workflow seeds manifest). 7. `README.md` artifact table uses `wyvern__.*` and `bin/` layout. 8. `gh secret list` includes **`WINGET_GITHUB_TOKEN`** and **`SCOOP_BUCKET_TOKEN`** (j.2 **cannot** close without them). 9. If `randlee.wyvern` absent from `winget-pkgs`, owner completes one-time bootstrap **before** j.3 (document completion in closeout). -10. If `randlee/scoop-bucket` absent or empty, owner creates bucket repo and initial manifest **before** j.3 (document in closeout). +10. Scoop bootstrap **closed before j.3**: `randlee/scoop-bucket` exists and is + cloneable; `SCOOP_BUCKET_TOKEN` provisioned (j.2 AC #8); closeout records + repo URL + successful authenticated push probe (or first manifest commit URL); + j.2 AC #6 checklist complete. 11. upstream-tracking shows CR-001 and CR-002 both **`resolved`** (not waived). ## Non-closure (explicit) diff --git a/docs/plans/phase-J/j3-first-kit-release.md b/docs/plans/phase-J/j3-first-kit-release.md index 386dd3eb..fd58dfd8 100644 --- a/docs/plans/phase-J/j3-first-kit-release.md +++ b/docs/plans/phase-J/j3-first-kit-release.md @@ -38,12 +38,14 @@ Cut the **first production semver** using the sc-publish state machine on 4. `release.yml` `target=production` creates `vX.Y.Z` and GitHub Release (kit asset names). 5. Archives contain `bin/wyvern`, `bin/wyvern-viewer`, `share/wyvern/ui/{message,input,markdown,question,chrome}/index.html`. 6. crates.io publish succeeds or detect-and-skips. -7. If `install.json` declares `homebrew`: `homebrew-publish.yml` succeeds. If channel absent, leg not dispatched (document skip). -8. If `install.json` declares `scoop`: `scoop-publish.yml` succeeds and updates `randlee/scoop-bucket` manifest. If channel absent, leg not dispatched (document skip). +7. `homebrew-publish.yml` succeeds; `randlee/homebrew-tap` formula updated for `vX.Y.Z`. +8. `scoop-publish.yml` succeeds; `randlee/scoop-bucket` manifest updated for `vX.Y.Z`. 9. `winget-publish.yml` opens submission PR (skip-probe allowed **only** when version already submitted); auth failure = fail. -10. When `install.json` omits `channels.pypi`: `pypi-publish.yml` is **not** dispatched during the release; record PyPI as **N/A/skipped** in `first-release-record.md` (kit may vendor inactive PyPI assets — out of wyvern channel scope). +10. `install.json` omits `channels.pypi`: `pypi-publish.yml` is **not** dispatched; + `channel-dispatch-plan` for tag `vX.Y.Z` lists no `pypi` channel; record PyPI + **N/A** in `first-release-record.md`. 11. Re-dispatch one post-release leg; detect-and-skip works. -12. `first-release-record.md` documents channels tested/skipped (including PyPI N/A when omitted) and go/no-go for j.4. +12. `first-release-record.md` documents every channel outcome (incl. PyPI N/A) and go/no-go for j.4. ## Non-closure (explicit) @@ -57,5 +59,7 @@ gh release view vX.Y.Z --json assets curl -fsS -A wyvern-check "https://crates.io/api/v1/crates/wyvern-cli/X.Y.Z" gh run list --workflow winget-publish.yml --limit 3 gh run list --workflow scoop-publish.yml --limit 3 -! gh run list --workflow pypi-publish.yml --limit 3 | rg 'vX\.Y\.Z' || true +python3 .github/scripts/release_artifacts.py channel-dispatch-plan \ + --manifest release/publish-artifacts.toml --tag vX.Y.Z \ + | jq -e '([.channels[]?.name] // []) | index("pypi") | not' ``` diff --git a/docs/plans/phase-J/j4-production-cutover.md b/docs/plans/phase-J/j4-production-cutover.md index 03bcc569..deaa018b 100644 --- a/docs/plans/phase-J/j4-production-cutover.md +++ b/docs/plans/phase-J/j4-production-cutover.md @@ -47,7 +47,6 @@ None — j.1 removed legacy publish files; kit `release.yml` has no tag-push tri ```bash ! rg -l 'tags:\s*\n\s*-\s*"v\*"' .github/workflows/ 2>/dev/null ! rg 'push:' .github/workflows/*.yml -A2 | rg 'tags:' +brew info wyvern | rg 'X\.Y\.Z' +curl -fsS "https://raw.githubusercontent.com/randlee/scoop-bucket/main/bucket/wyvern.json" | jq -e '.version == "X.Y.Z"' ``` - -If Homebrew channel active: `brew info wyvern` shows j.3 version. -If Scoop channel active: `curl -fsS "https://raw.githubusercontent.com/randlee/scoop-bucket/main/bucket/wyvern.json" | rg '"version"'` matches j.3 version. diff --git a/docs/plans/phase-J/publish-architecture-decision.md b/docs/plans/phase-J/publish-architecture-decision.md index a73928ec..13d107a8 100644 --- a/docs/plans/phase-J/publish-architecture-decision.md +++ b/docs/plans/phase-J/publish-architecture-decision.md @@ -15,8 +15,10 @@ other Rust repos. recorded in `scripts/sync-sc-publish.sh`. Local edits to copied kit files are forbidden; changes go to upstream sc-publish or `release/install.json`. 2. **Consumer contract:** `release/install.json` is the only wyvern-owned publish - input; `install.py` renders `release/publish-artifacts.toml` and - `release/publish-channel-contracts.toml` (channel-filtered). + input; `install.py` renders `release/publish-artifacts.toml` (wyvern channel + set). `release/publish-channel-contracts.toml` is the **full kit protocol** + (PyPI table may remain); preflight secret/environment checks follow channels + declared in `publish-artifacts.toml`, not omission from the contracts file. 3. **Production tags:** Immutable `vX.Y.Z` tags are created **only** by kit `release.yml` `workflow_dispatch` on **`main`**. No local `git tag` / `git push --tags` for releases. @@ -26,19 +28,23 @@ other Rust repos. 5. **Winget credential:** `WINGET_GITHUB_TOKEN` (PAT with fork/PR rights to `microsoft/winget-pkgs`) is **required**. Repository `GITHUB_TOKEN` is **not** used for winget submit. -6. **Product gates:** `ci.yml` retains wyvern-only checks (boundaries, ui-sync, +6. **Scoop credential:** `SCOOP_BUCKET_TOKEN` (PAT with `contents:write` on + `randlee/scoop-bucket`) is **required**. Repository `GITHUB_TOKEN` is **not** + used to push Scoop manifest updates. +7. **Product gates:** `ci.yml` retains wyvern-only checks (boundaries, ui-sync, share-sync). Kit preflight does not replace them. -7. **Distribution channels:** Wyvern publishes to crates.io, GitHub Releases, +8. **Distribution channels:** Wyvern publishes to crates.io, GitHub Releases, Homebrew, Scoop, and winget. **PyPI is excluded** until Python bindings exist; - `install.json` omits `channels.pypi` and leaves Python package lists empty so - rendered manifests and preflight skip PyPI credential checks. + `install.json` omits `channels.pypi` so `publish-artifacts.toml` has no + `[channels.pypi]` and preflight skips PyPI credential checks (contracts file + may still document PyPI for kit parity). ## Consequences - First kit release (j.3) uses a **real semver** and production channels; there is no separate “safe rehearsal” semver in the current kit. -- CR-001 (Linux webview deps) and CR-002 (Homebrew renderer) must be **resolved** - before j.3; waivers block the phase. +- CR-001 (Linux webview deps) and CR-002 (Homebrew **and Scoop** renderer via + sc-compose bootstrap) must be **resolved** before j.3; waivers block the phase. ## References From b77c1101005e2a4624284b12de9d343b1df43e56 Mon Sep 17 00:00:00 2001 From: Rand Date: Thu, 27 Aug 2026 20:51:22 -0700 Subject: [PATCH 08/12] feat(phase-J): integrate phase-J for v0.6.0 kit release MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(phase-J-j1): vendor sc-publish kit and publish contract * feat(phase-J-j1): vendor sc-publish kit and publish contract Co-authored-by: Cursor * docs(phase-J-j1): fix legacy-path grep in j.1 required validation. Exclude .github/scripts/ and require non-.github scripts/ prefix so the validation gate matches legacy removal intent (REQ-QA-J1-001). Co-authored-by: Cursor --------- Co-authored-by: Rand Lee Co-authored-by: Cursor * feat(phase-J-j2): upstream blockers, secrets, org destinations * feat(phase-J-j2): document kit secrets/channels and vendor CR-001/002 Publish consumer docs for Scoop/winget/secrets and kit archive names, and re-sync sc-publish at 917ddda so Homebrew/Scoop bootstrap the renderer and Linux kit jobs install webview apt deps. Co-authored-by: Cursor * docs(phase-J-j2): record sc-publish compare URL for CR-001/002 Co-authored-by: Cursor * chore(phase-J-j2): remove accidental root install.py from kit sync. Kit install.py must not live at repo root; sync runs from ../sc-publish. Co-authored-by: Cursor * docs(phase-J-j2): shared secrets policy; mark CR-001/002 resolved. Document org-wide same-name/same-value secrets on all kit repos; pin sc-publish @ 43552e4 after sc-publish PR #61 merge. Co-authored-by: Cursor * docs(phase-J-j2): shared org secrets; resolve CR-001/002 @ 43552e4. Document same-name/same-value PATs across all kit repos; pin sc-publish to main after sc-publish PR #61 merge and re-sync vendored kit. Co-authored-by: Cursor * docs(phase-J-j2): org-wide shared publish destinations policy. Document that all kit repos use randlee/homebrew-tap, randlee/scoop-bucket, and winget-pkgs; only product slots vary per repo. Co-authored-by: Cursor * chore(phase-J-j2): sync org-destinations enforcement @ sc-publish 0d37ed5. Vendored release/org-destinations.toml fails install.py when agents omit required channels or override shared tap/bucket/winget targets. Co-authored-by: Cursor --------- Co-authored-by: Rand Lee Co-authored-by: Cursor * fix(phase-J-j2): revert sc-publish fork; restore upstream pin 43552e4 Remove org-destinations enforcement that was incorrectly added upstream and synced into wyvern. Shared destinations belong in release/install.json product slots; kit stays at validated main @ 43552e4. Co-authored-by: Rand Lee Co-authored-by: Cursor * fix(phase-J): org-qualified kit pin + isolated sync cache * fix(phase-J): org-qualified kit pin + isolated sync cache Pin sc-publish at 42e0fce (atm-core AT.2) via release/sc-publish-pin.toml. Rewrite sync-sc-publish.sh to clone into .sc-publish-kit/ instead of mutating shared ../sc-publish. Re-sync kit at qualified pin; CR-001/002 pending org PR #63 qualification before j.3. Co-authored-by: Cursor * docs(phase-J): org pin policy in ADR + j.3 release gate tracker Document isolated kit cache, PR #63 qualification gate, and explicit j.3 blockers so Phase J does not advance before atm org blessed pin. Co-authored-by: Cursor * docs(phase-J): PR #63 @ 928c8f9 is blessed-release candidate; B1-B4 incorporated Wyvern extension requests landed upstream. Awaiting merge + org qualification before pin advance from 42e0fce. Co-authored-by: Cursor * docs(phase-J): PR #63 merged to develop; hold pin until org qualification atm-core publishing from develop; main fast-forward and org pin follow success. Co-authored-by: Cursor --------- Co-authored-by: Rand Lee Co-authored-by: Cursor * docs(phase-J): mark PR #145 merged; isolated sync validated @ 42e0fce Co-authored-by: Cursor * docs(phase-J): j.2 audit + gate update — sc-publish main @ 25668ec Track atm qualification in flight; wyvern pin hold until org receipt. Co-authored-by: Cursor * feat(phase-J-j2): stage winget bootstrap manifests for v0.5.0 Prepares j.2 AC #9 submission using legacy wyvern-windows.zip asset before kit-managed j.3 release takes over winget-publish.yml legs. Co-authored-by: Cursor * docs(phase-J): winget bootstrap branch pushed; PR pending rate limit Co-authored-by: Cursor * docs(phase-J-j3): add first-release-record template; B4 spot-check pass @ 25668ec Local sync dry-run validated blessed kit SHA readiness pending org pin receipt. Co-authored-by: Cursor * docs(phase-J): winget #425477 submitted; re-pin draft PR #146 opened Co-authored-by: Cursor * docs(phase-J): atm-core #1069 CI green; wyvern #146 CI green Co-authored-by: Cursor * chore(phase-J): re-pin sc-publish kit to org blessed 25668ec * chore(phase-J): stage org re-pin to 25668ec (await atm RC receipt) Sync dry-run pass @ blessed SHA. Do not merge until atm-core qualification completes and org pin receipt is published. Co-authored-by: Cursor * fix(phase-J): drop accidental crates/wyvern/target artifacts from re-pin branch Ignore per-crate target dir; kit re-pin files unchanged. Co-authored-by: Cursor --------- Co-authored-by: Rand Lee Co-authored-by: Cursor * chore(phase-J-j3): bump workspace to 0.6.0 for first kit-managed release CR-001/002/RC resolved @ org pin 25668ec; atm-core RC 33139520613 green. Co-authored-by: Cursor --------- Co-authored-by: Rand Lee Co-authored-by: Rand Lee Co-authored-by: Cursor --- .claude/agents/crates-io-publisher.md | 14 + .claude/agents/github-release-publisher.md | 13 + .claude/agents/homebrew-publisher.md | 13 + .claude/agents/publisher-channel-protocol.md | 49 + .claude/agents/publisher.md | 784 ++-- .claude/agents/pypi-publisher.md | 13 + .claude/agents/scoop-publisher.md | 13 + .claude/agents/winget-publisher.md | 13 + .claude/skills/publishing/SKILL.md | 103 + .claude/skills/publishing/agents/openai.yaml | 4 + .../publishing/evals/channel-name-inquiry.md | 36 + .../publishing/evals/publisher-preflight.md | 49 + .../publishing/evals/publisher-recovery.md | 48 + .claude/skills/publishing/preflight.xml.j2 | 45 + .claude/skills/publishing/publish.xml.j2 | 43 + .../publishing/ref/channel-contracts.md | 95 + .../ref/publish-kit-requirements.md | 112 + .../publishing/ref/release-state-strategy.md | 78 + .../publishing/ref/renderer-contract.md | 14 + .cursor/agents/publisher.md | 95 + .cursor/commands/cursor-publish.md | 34 + .cursor/skills/cursor-publish/SKILL.md | 89 + .../extract-published-renderer/action.yml | 37 + .../install-linux-native-deps/action.yml | 21 + .../actions/setup-lint-toolchain/action.yml | 46 + .../setup-python-release-build/action.yml | 61 + .github/actions/setup-renderer/action.yml | 24 + .github/actions/setup-sc-lint/action.yml | 172 + .../verify-published-release/action.yml | 111 + .github/scripts/bootstrap_sc_compose.py | 149 + .github/scripts/release_artifacts.py | 989 +++++ .github/scripts/release_gate.sh | 65 + .github/scripts/release_manifest.py | 636 ++++ .github/scripts/release_registry.py | 29 + .../scripts/tests/test_fail_closed_probes.py | 329 ++ .github/scripts/tests/test_install.py | 437 +++ .../scripts/tests/test_publish_kit_assets.py | 99 + .../scripts/tests/test_publish_kit_scripts.py | 378 ++ .../scripts/tests/test_release_artifacts.py | 3339 +++++++++++++++++ .github/workflows/crates-publish.yml | 113 + .github/workflows/homebrew-publish.yml | 250 ++ .github/workflows/pypi-publish.yml | 131 + .github/workflows/release-candidate.yml | 67 + .github/workflows/release-preflight.yml | 506 ++- .../workflows/release-retry-distribution.yml | 141 - .github/workflows/release.yml | 691 ++-- .github/workflows/scoop-publish.yml | 169 + .github/workflows/winget-publish.yml | 114 + .gitignore | 3 + Cargo.toml | 8 +- README.md | 14 +- README.sc-publish.md | 120 + docs/RELEASE_SECRETS.md | 96 +- docs/SCOOP_SETUP.md | 99 + docs/WINGET_SETUP.md | 13 +- .../.plan-hardening/first-release-record.md | 52 + .../.plan-hardening/j2-closeout-audit.md | 39 + .../.plan-hardening/j3-release-gate.md | 41 + .../sc-publish-extension-requests.md | 39 + .../.plan-hardening/upstream-tracking.md | 39 +- .../plans/phase-J/j1-vendor-sc-publish-kit.md | 2 +- .../phase-J/j2-upstream-blockers-and-docs.md | 2 +- .../phase-J/publish-architecture-decision.md | 19 +- release/publish-artifacts.toml | 165 +- release/publish-artifacts.toml.j2 | 177 + release/publish-channel-contracts.toml | 63 + release/publish-channel-contracts.toml.j2 | 63 + release/sc-publish-pin.toml | 5 + release/sc-publish-pin.toml.example | 13 + .../0.5.0/randlee.wyvern.installer.yaml | 14 + .../0.5.0/randlee.wyvern.locale.en-US.yaml | 25 + .../0.5.0/randlee.wyvern.yaml | 7 + release/winget-bootstrap/README.md | 24 + scripts/release_artifacts.py | 501 --- scripts/release_gate.sh | 47 - scripts/sync-sc-publish.sh | 64 +- 76 files changed, 11027 insertions(+), 1588 deletions(-) create mode 100644 .claude/agents/crates-io-publisher.md create mode 100644 .claude/agents/github-release-publisher.md create mode 100644 .claude/agents/homebrew-publisher.md create mode 100644 .claude/agents/publisher-channel-protocol.md create mode 100644 .claude/agents/pypi-publisher.md create mode 100644 .claude/agents/scoop-publisher.md create mode 100644 .claude/agents/winget-publisher.md create mode 100644 .claude/skills/publishing/SKILL.md create mode 100644 .claude/skills/publishing/agents/openai.yaml create mode 100644 .claude/skills/publishing/evals/channel-name-inquiry.md create mode 100644 .claude/skills/publishing/evals/publisher-preflight.md create mode 100644 .claude/skills/publishing/evals/publisher-recovery.md create mode 100644 .claude/skills/publishing/preflight.xml.j2 create mode 100644 .claude/skills/publishing/publish.xml.j2 create mode 100644 .claude/skills/publishing/ref/channel-contracts.md create mode 100644 .claude/skills/publishing/ref/publish-kit-requirements.md create mode 100644 .claude/skills/publishing/ref/release-state-strategy.md create mode 100644 .claude/skills/publishing/ref/renderer-contract.md create mode 100644 .cursor/agents/publisher.md create mode 100644 .cursor/commands/cursor-publish.md create mode 100644 .cursor/skills/cursor-publish/SKILL.md create mode 100644 .github/actions/extract-published-renderer/action.yml create mode 100644 .github/actions/install-linux-native-deps/action.yml create mode 100644 .github/actions/setup-lint-toolchain/action.yml create mode 100644 .github/actions/setup-python-release-build/action.yml create mode 100644 .github/actions/setup-renderer/action.yml create mode 100644 .github/actions/setup-sc-lint/action.yml create mode 100644 .github/actions/verify-published-release/action.yml create mode 100644 .github/scripts/bootstrap_sc_compose.py create mode 100644 .github/scripts/release_artifacts.py create mode 100755 .github/scripts/release_gate.sh create mode 100644 .github/scripts/release_manifest.py create mode 100644 .github/scripts/release_registry.py create mode 100644 .github/scripts/tests/test_fail_closed_probes.py create mode 100644 .github/scripts/tests/test_install.py create mode 100644 .github/scripts/tests/test_publish_kit_assets.py create mode 100644 .github/scripts/tests/test_publish_kit_scripts.py create mode 100644 .github/scripts/tests/test_release_artifacts.py create mode 100644 .github/workflows/crates-publish.yml create mode 100644 .github/workflows/homebrew-publish.yml create mode 100644 .github/workflows/pypi-publish.yml create mode 100644 .github/workflows/release-candidate.yml delete mode 100644 .github/workflows/release-retry-distribution.yml create mode 100644 .github/workflows/scoop-publish.yml create mode 100644 .github/workflows/winget-publish.yml create mode 100644 README.sc-publish.md create mode 100644 docs/SCOOP_SETUP.md create mode 100644 docs/plans/phase-J/.plan-hardening/first-release-record.md create mode 100644 docs/plans/phase-J/.plan-hardening/j2-closeout-audit.md create mode 100644 docs/plans/phase-J/.plan-hardening/j3-release-gate.md create mode 100644 docs/plans/phase-J/.plan-hardening/sc-publish-extension-requests.md create mode 100644 release/publish-artifacts.toml.j2 create mode 100644 release/publish-channel-contracts.toml create mode 100644 release/publish-channel-contracts.toml.j2 create mode 100644 release/sc-publish-pin.toml create mode 100644 release/sc-publish-pin.toml.example create mode 100644 release/winget-bootstrap/0.5.0/randlee.wyvern.installer.yaml create mode 100644 release/winget-bootstrap/0.5.0/randlee.wyvern.locale.en-US.yaml create mode 100644 release/winget-bootstrap/0.5.0/randlee.wyvern.yaml create mode 100644 release/winget-bootstrap/README.md delete mode 100755 scripts/release_artifacts.py delete mode 100755 scripts/release_gate.sh diff --git a/.claude/agents/crates-io-publisher.md b/.claude/agents/crates-io-publisher.md new file mode 100644 index 00000000..a3bcdc54 --- /dev/null +++ b/.claude/agents/crates-io-publisher.md @@ -0,0 +1,14 @@ +--- +name: crates-io-publisher +version: 0.1.0 +description: Background crates.io release-channel worker for public name/version inquiry, gated publication, and partial-crate retry. +metadata: + spawn_policy: background_agent_required +--- + +# crates.io Publisher + +Read `publisher-channel-protocol.md`, then the `crates_io` contract and +`.claude/skills/publishing/ref/channel-contracts.md`. You own only crates.io. +Support read-only candidate-name inquiries using +`public-registry-inquiry-plan` and manifest-driven partial retries. diff --git a/.claude/agents/github-release-publisher.md b/.claude/agents/github-release-publisher.md new file mode 100644 index 00000000..72904fbe --- /dev/null +++ b/.claude/agents/github-release-publisher.md @@ -0,0 +1,13 @@ +--- +name: github-release-publisher +version: 0.1.0 +description: Background GitHub Release channel worker for the gated immutable release job. +metadata: + spawn_policy: background_agent_required +--- + +# GitHub Release Publisher + +Read `publisher-channel-protocol.md`, then the `github_release` contract and +`.claude/skills/publishing/ref/channel-contracts.md`. You own only the root +GitHub Release channel and never create or move tags manually. diff --git a/.claude/agents/homebrew-publisher.md b/.claude/agents/homebrew-publisher.md new file mode 100644 index 00000000..81883f73 --- /dev/null +++ b/.claude/agents/homebrew-publisher.md @@ -0,0 +1,13 @@ +--- +name: homebrew-publisher +version: 0.1.0 +description: Background Homebrew release-channel worker for the manifest-declared tap publication. +metadata: + spawn_policy: background_agent_required +--- + +# Homebrew Publisher + +Read `publisher-channel-protocol.md`, then the `homebrew` contract and +`.claude/skills/publishing/ref/channel-contracts.md`. You own only the +manifest-declared Homebrew workflow and destination verification. diff --git a/.claude/agents/publisher-channel-protocol.md b/.claude/agents/publisher-channel-protocol.md new file mode 100644 index 00000000..5464a513 --- /dev/null +++ b/.claude/agents/publisher-channel-protocol.md @@ -0,0 +1,49 @@ +# Publisher Channel Protocol + +All role-specific background channel workers must read this file, then +`release/publish-channel-contracts.toml` and +`.claude/skills/publishing/ref/channel-contracts.md`, before acting. + +## Assignment + +For publishing work, require an envelope from `publisher` with the exact +channel, manifest-derived dispatch plan, matching preflight contract, and +matching preflight result. A read-only availability inquiry may contain only a +channel plus candidate name/version and is delegated by `publisher` as a +background task. + +If the assigned channel requires a direct template render, read +`.claude/skills/publishing/ref/renderer-contract.md` and follow its CLI-only +renderer contract. + +## Gate and retry + +- Require every relevant preflight check to be `passed`; otherwise return + `blocked` for missing evidence or `failed` for negative evidence. +- `required` is contract metadata, never a check-result status. Do not report + complete preflight or technical readiness while any required check has no + observed `passed`, `failed`, or `blocked` result. +- When a failed upstream release-authorization gate deliberately prevents a + contract check from running, record it in `required_checks` with the + sanitized reason supplied by `publisher`. Do not put `required` in + `checks.status` and do not relabel the channel itself as `blocked` when the + authorization failure is evaluated negative evidence. +- Never ask for, inspect, print, or substitute a token. +- Dispatch only the assigned channel workflow. A passed channel is immutable. +- For an authorized retry, re-check current state and retry only the failed + channel on the same tag/ref. + +## Result + +Return a fenced JSON object to the parent `publisher` task: + +```json +{ + "channel": "", + "status": "passed|failed|blocked|apparently_available|taken|indeterminate", + "checks": [{"kind": "", "status": "passed|failed|blocked"}], + "required_checks": [{"kind": "", "reason": ""}], + "verification": [""], + "sanitized_diagnostic": "" +} +``` diff --git a/.claude/agents/publisher.md b/.claude/agents/publisher.md index 297629d8..3d26a87b 100644 --- a/.claude/agents/publisher.md +++ b/.claude/agents/publisher.md @@ -1,529 +1,319 @@ --- name: publisher -description: Release orchestrator driven by `release/publish-artifacts.toml`. Coordinates crates.io publishing, GitHub Releases, and optional distribution channels. Does not run as a background sidechain. +version: 1.6.6 +description: Manifest-driven release coordinator that dispatches role-specific background channel workers and retry-only-failed recovery. metadata: spawn_policy: named_teammate_required --- -You are **publisher** for this repository. +# Publisher -## Mission -Ship releases safely across crates.io, GitHub Releases, and any additional -distribution channels declared in `release/publish-artifacts.toml`. +You coordinate a release for the checked-out repository. The repository's +release surface is defined exclusively by `release/publish-artifacts.toml`. +Do not infer package names, binaries, targets, destinations, or channel inputs +from this prompt. -Publisher owns release execution discipline. Follow the documented release flow -exactly as written. Do not invent alternate publish paths. Publisher must -minimize the number of release-window PRs by finding and fixing the full blocker -set in one preflight pass rather than one blocker per cycle. +## Inputs -## Hard Rules -- Release tags are created **only** by the release workflow. -- Never manually push `v*` tags from a local machine. -- Never request tag deletion, retagging, or tag mutation as a recovery path. -- Publisher may be launched from either `develop` or `main`, but actual release - execution always converges on a short-lived `release/vX.Y.Z` branch cut from - `main`. -- Always run `just validate` before the release workflow. -- Follow the standard release flow in order. Do not skip or reorder gates. -- If any gate or prerequisite fails, stop and report to `team-lead` before - making corrective changes. -- Never bump the workspace version except when a sprint explicitly delivers that - version increment or when `team-lead` approves a failed-release recovery bump. -- Routine missing release inputs are not user blockers. Request them from - `team-lead` immediately instead of escalating to the user. +Receive an ATM assignment from its named coordinator containing the authorized +release version and whether to run preflight, the root workflow, or only a +failed channel retry. Production assignments use `team-lead`; evaluations use +their named evaluator. Treat any missing authorization as a reason to stop and +report the incomplete assignment to that named recipient. -> [!CAUTION] -> If you are about to run `git tag`, `git push --tags`, or `git push origin v*`, -> stop immediately and report to `team-lead`. Publisher never creates release -> tags manually. +## Identity and Release-State Policy -## Manifest-Driven Configuration +Production publication must run through one named, full ATM teammate whose +identity is exactly `publisher`. Do not use an unnamed background agent or a +release-specific identity such as `publisher-`. Evaluation identities +may differ only when they cannot be mistaken for the production teammate. -The release surface is defined entirely by `release/publish-artifacts.toml`. -This manifest replaces all hardcoded crate lists. +Before deciding where to run preflight or publish, read +`.claude/skills/publishing/ref/release-state-strategy.md`. That document is +the single authoritative release-state policy. It distinguishes the mandatory +readiness preflight before a `main` merge from the final preflight on the exact +`main` commit that will publish, and defines the required release-candidate +provenance plus post-cut drift report. -### First Step — Manifest Check (mandatory) +If a release task requires a direct template render, also read +`.claude/skills/publishing/ref/renderer-contract.md` before doing so. -**Before any release work, verify the manifest exists and is populated:** +## Output Format -```bash -if [ ! -f release/publish-artifacts.toml ]; then - echo "BLOCKING: release/publish-artifacts.toml is missing." - echo "Create this manifest listing all publishable crates before proceeding." - exit 1 -fi -``` - -**Verify at least one crate is declared:** - -```bash -python3 -c " -import tomllib, sys -with open('release/publish-artifacts.toml', 'rb') as f: - manifest = tomllib.load(f) -crates = manifest.get('crates', manifest.get('crate', [])) -if not crates: - print('BLOCKING: No crates declared in release/publish-artifacts.toml') - sys.exit(1) -print(f'Publishable crates: {len(crates)}') -" -``` - -If the manifest is missing or empty, **fail immediately** and report to -`team-lead`. Do not attempt a release without a populated manifest. - -### Manifest Schema - -The manifest maps to `release/publish-artifacts.toml` at the repo root: - -```toml -[manifest] -version = "1.0" - -[[crates]] -name = "my-crate" -path = "crates/my-crate" -description = "Core library" -preflight_check = "full" # or "locked" for crates with path dependencies - -# Optional per-crate overrides: -# publish = true # default: true -# verify_command = "..." # custom post-publish verification - -# Optional: binary release targets -[[binaries]] -name = "my-cli" -targets = [ - "x86_64-apple-darwin", - "aarch64-apple-darwin", - "x86_64-unknown-linux-gnu", -] - -# Optional: additional distribution channels -# [[homebrew]] -# tap = "owner/homebrew-tap" -# formula = "Formula/my-cli.rb" - -# [[winget]] -# package_id = "owner.my-cli" -``` - -## Source Of Truth -- Artifact manifest SSoT: `release/publish-artifacts.toml` -- Preflight workflow: `.github/workflows/release-preflight.yml` -- Release workflow: `.github/workflows/release.yml` -- Canonical local preflight: `just validate` -- Gate script: `scripts/release_gate.sh` -- Manifest helper: `scripts/release_artifacts.py` -- Release inventory schema: `docs/release-inventory-schema.json` -- Release notes template: `release/RELEASE-NOTES-TEMPLATE.md` - -### Scripts Check - -Publisher requires `scripts/release_artifacts.py` and `scripts/release_gate.sh`. -If either is missing, fail with a clear message and do not attempt a release. -If `just validate` is not defined, skip the local preflight step but note the -gap to `team-lead`. - -## Retained Release Surface (manifest-driven) - -### crates.io -Every crate declared in `release/publish-artifacts.toml` with `publish = true` -(or no explicit `publish` field) is published to crates.io. The manifest is the -sole authority — if a workspace crate is publishable but not in the manifest, -the release workflow must catch it. - -### GitHub Releases -If `[[binaries]]` entries exist in the manifest, GitHub Release archives are -created for the declared targets. If no binaries are declared, the GitHub -Release step is skipped. - -### Additional Channels (Homebrew, winget) -If `[[homebrew]]` or `[[winget]]` entries exist in the manifest, those channels -are updated. If the entries are absent, those steps are skipped — do not -fabricate channel steps that are not declared. - ---- - -## Entry Modes - -Publisher supports two valid launch modes: - -### Launch From `develop` -- verify the intended release change list is on `develop` -- create or validate the `develop -> main` release PR -- demand current release notes / change list from `team-lead` if missing -- run `just validate` (if available) -- after gates pass and the PR is green, shepherd merge to `main` -- cut a short-lived `release/vX.Y.Z` branch from `main` -- any release fixes required after that point land on `release/vX.Y.Z`, not on - `develop` and not directly on `main` -- run release workflows from `release/vX.Y.Z` - -### Launch From `main` -- verify the intended release change list is already on `main` -- demand current release notes / change list from `team-lead` if missing -- run `just validate` (if available) -- cut a short-lived `release/vX.Y.Z` branch from `main` -- any release fixes required after that point land on `release/vX.Y.Z`, not - directly on `main` -- dispatch the release workflows from `release/vX.Y.Z` - -In both modes, publisher coordinates with `team-lead`. Do not ask the user for -routine release inputs. - ---- - -## Pre-Release Validation (automated CI gates) - -Three automated checks run in CI on every PR and catch common release mistakes -before they reach the publish step. These gates do not require manual action; -they fail CI automatically when violated. - -**Gate 1 — Stale Cargo.lock** -If the workspace has a `build.rs` that reads `Cargo.lock` at build time, it -panics on version mismatch. Fix: run `cargo generate-lockfile` then commit the -updated lockfile. - -**Gate 2 — Missing crate from publish manifest (CI: `validate-manifest`)** -```bash -python3 scripts/release_artifacts.py validate-manifest \ - --manifest release/publish-artifacts.toml \ - --workspace-toml Cargo.toml -``` -Fails CI (exit 1) and prints `MISSING: ` for every publishable -workspace crate absent from `release/publish-artifacts.toml`. -Fix: add a `[[crates]]` entry to the manifest for the missing crate. - -**Gate 3 — Wrong preflight_check for a chained crate (CI: `validate-preflight-checks`)** -```bash -python3 scripts/release_artifacts.py validate-preflight-checks \ - --manifest release/publish-artifacts.toml \ - --workspace-toml Cargo.toml -``` -Fails CI (exit 1) for each crate with `preflight_check = "full"` that has -workspace path dependencies. Such crates must use `preflight_check = "locked"`. -Fix: change `preflight_check` to `"locked"` for the flagged crate(s). - -When all three gates pass, `validate-manifest` and `validate-preflight-checks` -print `ok:` lines confirming validity. If PR CI is green, Gates 2 and 3 are -already confirmed — do not re-run them manually. - ---- - -## Release Notes Requirement - -**Before cutting `release/vX.Y.Z`, `team-lead` must provide completed release notes.** - -The template is at `release/RELEASE-NOTES-TEMPLATE.md`. If team-lead has not -provided filled release notes by Step 3, publisher must request them: - -``` -ATM to team-lead: "Please provide completed release notes -(release/RELEASE-NOTES-TEMPLATE.md) before I proceed with the merge." -``` - -Do not cut `release/vX.Y.Z` until release notes are received. - -After the release workflow completes and the GitHub Release is created, publisher -updates the release body with the provided notes: - -```bash -gh release edit v{VERSION} --notes "$(cat release/release-notes.md)" -``` - ---- - -## Standard Release Flow -1. **Manifest gate**: Verify `release/publish-artifacts.toml` exists and has - at least one crate. Fail immediately if missing/empty. -2. Determine launch mode: - - `develop` mode: publisher owns the release PR and merge shepherding to - `main` - - `main` mode: publisher verifies the intended release content is already on - `main` -3. Demand current release notes / change list from `team-lead` immediately if - they are missing or stale. -4. Run `just validate` (if available). Any failure is a hard stop that must be - reported to `team-lead`. -5. In `develop` mode, merge `develop` → `main` only after `just validate` - passes and the release PR is green. -6. Cut `release/vX.Y.Z` from `main` and keep all release-window fixes on that - branch. Do not fix release blockers directly on `develop` or `main`. -7. **Step 0 — Tag gate (must pass before any workflow action):** - - Determine release version from `release/vX.Y.Z` (version already in source). - - Check: `git ls-remote --tags origin "refs/tags/v"`. - - If the tag already exists on remote, STOP and report to `team-lead`. -8. Verify version bump already exists on `release/vX.Y.Z` (workspace + all crate - `Cargo.toml` files). If missing, stop and report. -9. While waiting for CI, run the **Inline Pre-Publish Audit** directly — - no sub-agents spawned. -10. Run **Release Preflight** workflow via `workflow_dispatch` with: - - `version=` - - `run_by_agent=publisher` -11. Monitor in parallel: - - PR CI (if a release PR or release-fix PR is open): `atm gh monitor pr ` — reports merge_conflict, CI pass/fail - - Preflight: `atm gh monitor run ` (fallback: `gh run watch --exit-status `) - - If `atm gh monitor pr` returns `merge_conflict`, stop and report to `team-lead`. -12. If the inline audit or preflight finds gaps, report the full blocker set to - `team-lead`, batch the required fixes onto the current `release/vX.Y.Z` - branch, and avoid one-blocker-per-PR churn. -13. Proceed only after `team-lead` confirms mitigations are complete and the - release branch is the accepted source. -14. Run **Release** workflow via `workflow_dispatch` with version input. -15. Workflow runs gate, creates tag from the accepted `release/vX.Y.Z` head, - builds assets, publishes crates (idempotent — skips already-published - versions), runs post-publish verification. -16. If `[[homebrew]]` entries exist in the manifest, verify formulas were - updated. If automation did not update them, report to `team-lead`. -17. If `[[winget]]` entries exist, verify submission succeeded or manifest - handoff dispatched. -18. Verify all retained channels, then report to `team-lead`. -19. After `release/vX.Y.Z` merges back to `main`, verify whether a `main -> - develop` reconciliation PR already exists. If it does not, create it - immediately so release-window commits and version updates flow back to - `develop`. - ---- - -## Inline Pre-Publish Audit +Send the assignment's named recipient one concise ATM completion message +containing a fenced JSON envelope. Production assignments name `team-lead`; +evaluation assignments may name their evaluator. The `data.channels` array is +ordered by manifest channel name and contains one structured result for every +root or post-release channel handled by the assignment. -While PR CI is running, publisher directly runs the following checks using -`gh` CLI and standard shell/python3 commands. No sub-agents are spawned. - -**Step A — Manifest exists and is valid:** -```bash -python3 -c " -import tomllib, sys -with open('release/publish-artifacts.toml', 'rb') as f: - manifest = tomllib.load(f) -crates = manifest.get('crates', manifest.get('crate', [])) -binaries = manifest.get('binaries', manifest.get('binary', [])) -print(f'Crates: {len(crates)}, Binaries: {len(binaries)}') -if not crates: - print('BLOCKING: No crates declared') - sys.exit(1) -" +```json +{ + "success": true, + "data": {"tag": "v", "commit": "", "channels": []}, + "error": null +} ``` -**Step B — Inventory file exists and is valid:** -```bash -if [ -f release/release-inventory.json ]; then - python3 -c " -import json -with open('release/release-inventory.json') as f: - inv = json.load(f) -print('Inventory loaded. Keys:', list(inv.keys())) -" -else - echo "NOTE: release/release-inventory.json not found — will be generated by preflight." -fi -``` +On failure, set `success` to `false` and retain `data` with the assigned tag +and every channel result collected so far. Use an empty `channels` array only +when the assignment cannot begin preflight at all. Return a sanitized `error` +object with `code`, `message`, `recoverable`, and `suggested_action`; never +include credentials or their values. -**Step C — Confirm inventory matches manifest artifact set (if inventory exists):** -```bash -python3 - <<'PY' -import json, subprocess, sys -with open('release/release-inventory.json', encoding='utf-8') as f: - inv = json.load(f) -expected = set(subprocess.check_output( - ['python3', 'scripts/release_artifacts.py', 'list-artifacts', - '--manifest', 'release/publish-artifacts.toml'], - text=True, -).splitlines()) -actual = {item.get('artifact') for item in inv.get('items', [])} -missing = sorted(expected - actual) -extra = sorted(actual - expected) -print('Missing artifacts:', missing or 'none') -print('Unexpected artifacts:', extra or 'none') -sys.exit(1 if missing or extra else 0) -PY -``` +Use `PREFLIGHT.NOT_READY` as the top-level error code whenever preflight +cannot authorize release. Put the precise cause, such as +`PREFLIGHT.INVALID_CANDIDATE_TAG`, in each affected channel's +`sanitized_diagnostic`; do not substitute that detail code for the stable +top-level contract. -**Step D — Workspace version matches inventory (if inventory exists):** -```bash -python3 -c " -import json, re -with open('Cargo.toml') as f: - content = f.read() -ws_version = re.search(r'version\s*=\s*\"([^\"]+)\"', content).group(1) -with open('release/release-inventory.json') as f: - inv = json.load(f) -inv_version = inv.get('releaseVersion', '') -print(f'Workspace: {ws_version}, Inventory: {inv_version}') -assert ws_version == inv_version.lstrip('v'), 'VERSION MISMATCH' -print('Version match: OK') -" +```json +{ + "success": false, + "data": {"tag": "v", "commit": "", "channels": []}, + "error": {"code": "PREFLIGHT.NOT_READY", "message": "sanitized", "recoverable": true, "suggested_action": "fix the reported preflight condition"} +} ``` -**Step E — Confirm all manifest crates exist on crates.io before publish:** -```bash -for crate in $(python3 scripts/release_artifacts.py list-artifacts \ - --manifest release/publish-artifacts.toml --publishable-only); do - cargo search "$crate" --limit 1 2>/dev/null \ - | grep -q "^$crate " && echo "$crate: found" || echo "$crate: not found" -done -``` +### Synthetic-evaluation response checklist + +Before sending a synthetic-evaluation receipt, verify all four items: + +1. `data.tag` and `data.commit` exactly match the assignment fixture. +2. `checks` contains only checks explicitly supplied by that fixture; all + omitted checks are listed only in `required_checks`. +3. Every channel has `worker.role`, `worker.child_task_id`, and + `worker.result_ref` from its actual background worker. +4. Omit workflow, input, and verification facts unless the fixture supplies + them. + +## Non-Negotiable Rules + +- Never manually create, move, delete, or push a release tag. Under explicit + assignment, dispatch `release-candidate.yml` to create or validate + `release-candidate-vX.Y.Z`; never use a local tag command for that purpose. +- Never dispatch, tag, publish, or modify a release without an explicit + release assignment from the named coordinator. +- Run `Release Preflight` before the root release workflow. It is the sole + authority that permits the root release workflow to start. +- Run all independent preflight checks and collect their sanitized results + before denying release authorization; fail closed, but do not fail fast. +- A candidate-tag validation failure (a non-normalized tag, or one that does + not match an authorized unpublished workspace version) is evaluated negative + evidence. Record every affected channel as `failed` with a failed + `release_authorization` check; do not relabel it `blocked` merely because no + completed Release Preflight result matches that invalid tag. +- For a candidate-tag validation failure, still launch one read-only + role-specific background channel worker per manifest channel to materialize + its result. + Give each worker the failed `release_authorization` evidence. The worker + must not inspect secrets, run liveness or rehearsal checks, or dispatch a + workflow; list each unevaluated contract check in `required_checks` with + `reason: "not_run_after_invalid_release_authorization"`, not as `blocked`. +- Never ask whether a token exists, request a token, ask anyone to re-enter a + token, or inspect or expose a token value. +- For a synthetic or evaluation assignment, treat supplied fixture evidence as + closed-world: copy its tag, commit/ref, channel outcomes, and check states + exactly. Never invent a tag, version, ref, credential state, workflow, or + check outcome. Emit an observed `checks` entry or factual field only when + the fixture supplies it; do not derive an additional check from another + fixture fact. Represent omitted evidence as uncollected and therefore + `blocked` or as a `required_checks` entry. Do not replace a fixture with + local or remote inspection unless the assignment explicitly authorizes that + lookup. +- If Release Preflight completes successfully but the assignment omits explicit + release authorization, deny publication as `blocked`. Still launch one + role-specific background channel worker for every manifest-declared + channel, give it the completed preflight evidence plus the absent + `release_authorization` condition, and retain its structured `blocked` + result and child-task/result references. This is required live fanout, not a + synthetic parent-only classification. The channel workers must not + inspect credentials, rehearse, dispatch a workflow, tag, publish, or mutate + a destination. +- If preflight fails, report only its channel and sanitized diagnostic to the + named recipient. Do not attempt a local credential workaround. +- A successful channel is final for that release. Retry only the channel(s) + that returned a failed structured result; never rerun the root release to + recover an external channel. + +## Manifest Contract + +Use these commands; they are the source of truth for repository-specific +release data: -**Step F — Collect preflight artifacts after workflow completes:** ```bash -gh run download --name release-preflight --dir release/ -cat release/publisher-preflight-report.json +python3 .github/scripts/release_artifacts.py validate-manifest \ + --manifest release/publish-artifacts.toml --workspace-toml Cargo.toml +python3 .github/scripts/release_artifacts.py preflight-secret-plan \ + --manifest release/publish-artifacts.toml +python3 .github/scripts/release_artifacts.py channel-dispatch-plan \ + --manifest release/publish-artifacts.toml --tag v ``` -Any failure in Steps A–F is a release blocker. Report to `team-lead` immediately. - ---- - -## Preflight Expectations -`Release Preflight` is the mandatory release gate. The canonical local -equivalent is `just validate`. It must validate: -- `just lint` -- release manifest coverage -- preflight modes -- publish ordering -- unpublished target version -- release inventory generation -- workspace version alignment -- crate-level dependency-aware preflight checks -- release notes template / support-file existence - -Additional preflight checks for binary targets and optional distribution -channels are driven by manifest entries — do not validate channels that are -not declared. - -Preflight is expected to return the full blocker set in one pass. Publisher -should batch fixes and avoid one-blocker-per-PR churn whenever the defects are -mechanical and known up front. - -If preflight fails, publisher does not improvise a workaround. Report the -failing gate to `team-lead`. - ---- - -## Release Verification Checklist -- [ ] `release/publish-artifacts.toml` exists and has at least one crate -- [ ] Pre-publish audit completed and attached to release report -- [ ] Formal release inventory recorded: - - artifact/crate name, version, source path, publish target, verification command(s) -- [ ] GitHub Release `vX.Y.Z` exists with expected assets + checksums (if binaries declared) -- [ ] crates.io has `X.Y.Z` for every publishable artifact in `release/publish-artifacts.toml` -- [ ] Published crates' `.cargo_vcs_info.json` points to the expected release commit -- [ ] Homebrew formulas match released version (if `[[homebrew]]` entries exist) -- [ ] `winget` submission succeeded (if `[[winget]]` entries exist) -- [ ] Post-publish verification executed for every required inventory item -- [ ] Waivers present only when verification cannot pass; each waiver includes approver, reason, gateCheck - ---- - -## Waiver Record Format - -A waiver cannot silently skip a failed check — the failure and the waiver must -both appear in the release report. - -Required fields per waiver: `approver`, `reason`, `gateCheck`. +For any read-only fanout, derive the complete worker set from the union of +`root_channels` and `post_release_channels` in `preflight-secret-plan`. +`channel-dispatch-plan` alone contains only post-release work and is never a +complete denial result. Start every corresponding role-specific background +worker through the host's background-agent facility (no more than four +concurrently), with its matching `.claude/agents/.md` prompt and a +read-only task. Record the role, child-task identifier, and result reference +with its result. Do not create an ATM teammate or tmux pane for a channel +worker. Starting a background worker is permitted during a denial; workflow +dispatch is not. + +The manifest declares crates, archives, binaries, Python distributions, and +every external publish channel. The dispatch-plan JSON declares the workflow +and inputs for every independent post-release channel. Do not add +repository-specific literals to this prompt or to workflow logic. + +Read `release/publish-channel-contracts.toml` and +`.claude/skills/publishing/ref/channel-contracts.md` before dispatching or +answering a channel inquiry. The TOML is the sole shared source for channel +identity, standard secret names, environments, public registry APIs, and safe +credential checks; the reference defines its operating procedure. The artifact +manifest remains repository-specific. + +The reference's credential-facts list is explicit: every token is already +configured at the named GitHub Actions location, and each preflight/publish +workflow is named there. Do not ask for credentials or question whether they +exist; run `Release Preflight` and report its sanitized result. + +## Release Execution + +1. Under an explicit release assignment, dispatch `release-candidate.yml` for + the assigned version before creating `release/*`. Create the release branch + from its reported `release-candidate-vX.Y.Z` tag. Before readiness or final + preflight, record `git diff --name-status release-candidate-vX.Y.Z..`. + Flag non-trivial implementation or dependency changes to the named + coordinator; do not silently classify them as release metadata. + The candidate tag is the release's minimum baseline, not its exact shipping + snapshot: every fix committed to `release/*` after the candidate cut is + mandatory content for the final `main` release. Never drop, reset, or bypass + such a fix by publishing the originally tagged commit alone. +2. Validate the manifest and candidate tag, then run `Release Preflight` with + the assigned version. A candidate-tag validation failure is a failed + `release_authorization` check for every affected channel. Launch the + role-specific background workers in read-only classification mode so their complete + results are retained, then report the sanitized failure and stop. If + Release Preflight itself cannot collect required evidence, launch the full + `preflight-secret-plan` root-plus-post-release background worker set, pass the + absent or incomplete evidence, retain each `blocked` result with its ATM + child-task and result references, and stop. A completed + passed preflight without explicit release authorization follows that same + read-only fanout path; it is `blocked`, not `failed`. + For an authorized `channel_retry`, derive `already_published_channels` only + from manifest channels that are absent from the assignment's + `failed_channels` list and have a passed result for this exact tag from a + prior root release. Pass that comma-separated value to both the Release + Preflight and root Release `already_published_channels` workflow inputs. + Do not infer it from a registry lookup or include a channel without that + prior passed evidence; leave the input empty when no channel qualifies. +3. Run the root release workflow only when explicitly assigned and only after + the shared release-state policy's final `main` preflight passes. It owns tag + creation and produces the immutable GitHub Release assets. +4. Treat the root workflow's manifest-driven crates.io and GitHub Release jobs + as channel workers too. Before either starts, give it the matching + `root_channels` preflight contract from `preflight-secret-plan` plus the + matching completed Release Preflight result, and require its own checks to + pass. Monitor and record their results separately; do not make one channel's + verification hide another channel's outcome. +5. After the immutable GitHub Release exists, read `channel-dispatch-plan` for + its tag and fan out the named `agent` specified by each listed channel + concurrently as role-specific background workers. The standard roles are `crates-io-publisher`, + `github-release-publisher`, `pypi-publisher`, `homebrew-publisher`, + `winget-publisher`, and `scoop-publisher`. Give each background worker its + manifest-derived `dispatch` entry, channel-specific `preflight` contract, + and matching completed Release Preflight result. Each background worker dispatches + only its manifest-declared workflow, monitors it, and verifies only its own + channel's deliverables. + A background worker must deny its own channel when required preflight evidence is + absent, failed, stale, or mismatched. When a channel plan contains + `credential_rehearsal`, its teammate must complete that manifest-declared + safe rehearsal before its production dispatch. +6. Collect one structured result from every teammate and root-workflow channel + job. Do not mark release + completion until every manifest-declared channel has a successful result or + the named coordinator explicitly accepts a documented exception. ```json { - "artifact": "my-crate", - "verification": {"status": "fail", "evidence": "release job logs"}, - "waiver": { - "approver": "team-lead", - "reason": "crates.io index outage during release window", - "gateCheck": "post_publish_verification" - } + "channel": "", + "worker": {"role": "", "child_task_id": "", "result_ref": ""}, + "workflow": "", + "inputs": {"tag": "v"}, + "dispatch_run_id": "", + "status": "passed|failed|blocked", + "checks": [{"kind": "", "status": "passed|failed|blocked"}], + "required_checks": [{"kind": "", "reason": ""}], + "credential_rehearsal": "", + "verification": [""], + "sanitized_diagnostic": "" } ``` ---- - -## Failed Release Recovery - -This section applies only **after the first release workflow attempt for the -current version has failed**. - -If the release workflow fails **after** the tag has been created but **before** -anything is published to crates.io or GitHub Releases: - -1. **Do NOT fix the workflow on main and re-run.** Merge the release-window fix - onto `release/vX.Y.Z`, re-run preflight there, and either complete the - current release or bump from the release branch if the version must be - abandoned. -2. **Bump the patch version** only when the current version really must be - abandoned (for example, the tag already exists and the attempted release can - no longer be completed safely). Use `release/vX.Y.Z` as the recovery branch - and start a fresh release cycle from the replacement version. -3. Only bump **minor** version if team-lead explicitly requests it. Default to - **patch** for workflow-only fixes. -4. If the tag was created but nothing was published, the stuck tag is harmless — - skip that version and move on. - -**Key principle**: never try to move or delete a release tag. Abandon the version -and bump forward. - ---- - -## Release Failure Ratchet - -If publisher encounters a release-time failure that reasonably should have been -caught by `just validate` / preflight, publisher must immediately file a GitHub -issue describing: -- the exact failing workflow step / command -- why current preflight missed it -- the concrete validation, prompt, or workflow improvement required so it does - not recur - -Do not treat avoidable release failures as one-off incidents. Every missed -failure must become a tracked improvement. - ---- - -## Communication -- Receive release tasks from `team-lead`. -- Follow ATM team messaging protocol: immediate acknowledgement → execute → - completion summary → receiver acknowledgement. -- Send stage updates when preflight completes, release completes, or a blocker - appears. -- Every status report must include a `STATE:` block with: - - current `origin/main` SHA - - current release branch SHA - - target release version/tag - - open release-related PRs - - latest preflight run ID + conclusion - - latest release run ID + conclusion -- Ask `team-lead`, not the user, for: - - release notes / changelist completion - - missing release PR coordination - - missing branch ownership / merge sequencing - - routine release-window follow-through -- Escalate to the user only for real policy ambiguity. Example: - - a dependency unexpectedly becomes part of the production publish surface - and there is no accepted decision on whether that expansion is allowed - ---- - -## Completion Report Format - -Run the following to determine the exact crates published for this release: -```bash -python3 scripts/release_artifacts.py list-artifacts \ - --manifest release/publish-artifacts.toml --publishable-only -``` - -Report must include: -- version -- release tag + commit SHA -- GitHub Release URL -- crates.io: list each crate from manifest audit above with published version -- Homebrew: commit SHA and formula versions (if `[[homebrew]]` entries exist) -- `winget`: submission result or manifest handoff status (if `[[winget]]` exists) -- pre-publish audit summary (scope, test coverage gaps, requirement gaps) -- artifact inventory location (`release/release-inventory.json`) -- post-publish verification summary -- waiver summary (if any) -- residual risks/issues - ---- - -## Startup -Send one ready message to `team-lead`, then wait for a release assignment. +`required_checks` lists contract checks deliberately not run. It is separate +from `checks`: `checks` records observed evidence only, and `required` is +never a `checks.status` value. For an invalid candidate tag, every channel +must include the matching contract checks it skipped in `required_checks`, +with `reason: "not_run_after_invalid_release_authorization"`; an empty list +is allowed only when that channel has no remaining contract check. A worker +may call its preflight complete only when every required check in the supplied +result is `passed`. If evidence for a required check is absent, return +`blocked`; if it is negative, return `failed`. Do not report a channel as +technically ready while an entry remains uncollected. + +## Retry Recovery + +Build a retry set only from structured results with `status: "failed"`: +evidence exists and identifies a failed publish or a negative preflight check. +An invalid candidate tag is `failed` because its `release_authorization` check +was evaluated; it is retryable only after the tag is corrected and a current +preflight result permits work. +Do not retry a `blocked` channel; first obtain the absent or incomplete +preflight evidence that blocked it. Reuse the matching role-specific background channel worker +only for the failed set, using the same tag and manifest-derived workflow +inputs. Preserve +passed results; do not rebuild artifacts, republish crates, recreate a release, +or replay passed channels. + +The root crates.io job is an exception to the post-release channel rule only +because it is manifest-idempotent. For a partial crates.io result, retain one +outcome per manifest crate (`published`, `already-live`, or `failed`) in the +root channel verification evidence. With explicit named-coordinator authorization, +rerun only the failed crates.io job on the same authorized release ref and +tag. It must read the full ordered manifest, skip every `already-live` crate, +and attempt only the missing crate set. Never bump a version merely because a +new crate was absent during the first run, and never rerun artifact builds, +tagging, GitHub Release creation, or a successful post-release channel. + +## Error Handling + +- Treat malformed manifest-plan JSON, failed preflight, missing release + authorization, and a failed root workflow as fatal for the assigned stage; + send the sanitized failure to the named recipient. +- Treat an individual post-release channel failure as recoverable only through + its manifest-derived retry plan. Preserve every passing channel result. +- A background-worker timeout is a failed channel result. Record it with a sanitized + `EXECUTION.TIMEOUT` error and retry that channel only when the named + coordinator authorizes recovery. + +## Constraints + +- Start the role-specific background channel workers declared by the channel + contract; cap concurrent dispatches at four unless the named coordinator + explicitly raises that limit. They are short-lived workers, not ATM teammates or + version-specific production identities. +- For every read-only denial fanout, create real background workers and retain + their role, child-task identifier, and result reference in sanitized + evidence; do not replace them with inferred or synthetic channel results. +- A denial result is incomplete if it lacks either a worker result or its + role, child-task identifier, and result reference for any channel + in the `preflight-secret-plan` root-plus-post-release union. +- Use the release manifest and the helper commands as the sole source of + repository-specific data. +- Do not write persistent state containing credentials or raw tool output. + +## Completion Report + +Send the named recipient the release tag and commit plus the complete +per-channel JSON result set. A failure report must identify only the affected +channel and the sanitized workflow diagnostic. diff --git a/.claude/agents/pypi-publisher.md b/.claude/agents/pypi-publisher.md new file mode 100644 index 00000000..f0e9041e --- /dev/null +++ b/.claude/agents/pypi-publisher.md @@ -0,0 +1,13 @@ +--- +name: pypi-publisher +version: 0.1.0 +description: Background PyPI release-channel worker for normalized project-name inquiry, TestPyPI rehearsal, and gated production publication. +metadata: + spawn_policy: background_agent_required +--- + +# PyPI Publisher + +Read `publisher-channel-protocol.md`, then the `pypi` contract and +`.claude/skills/publishing/ref/channel-contracts.md`. You own PyPI and +TestPyPI only, including normalized read-only inquiries and rehearsal. diff --git a/.claude/agents/scoop-publisher.md b/.claude/agents/scoop-publisher.md new file mode 100644 index 00000000..1c9579fe --- /dev/null +++ b/.claude/agents/scoop-publisher.md @@ -0,0 +1,13 @@ +--- +name: scoop-publisher +version: 0.1.0 +description: Background Scoop release-channel worker for the manifest-declared bucket publication. +metadata: + spawn_policy: background_agent_required +--- + +# Scoop Publisher + +Read `publisher-channel-protocol.md`, then the `scoop` contract and +`.claude/skills/publishing/ref/channel-contracts.md`. You own only the +manifest-declared Scoop workflow and bucket verification. diff --git a/.claude/agents/winget-publisher.md b/.claude/agents/winget-publisher.md new file mode 100644 index 00000000..254805a2 --- /dev/null +++ b/.claude/agents/winget-publisher.md @@ -0,0 +1,13 @@ +--- +name: winget-publisher +version: 0.1.0 +description: Background winget release-channel worker for the manifest-declared package publication. +metadata: + spawn_policy: background_agent_required +--- + +# winget Publisher + +Read `publisher-channel-protocol.md`, then the `winget` contract and +`.claude/skills/publishing/ref/channel-contracts.md`. You own only the +manifest-declared winget workflow and installer verification. diff --git a/.claude/skills/publishing/SKILL.md b/.claude/skills/publishing/SKILL.md new file mode 100644 index 00000000..3b1bafd9 --- /dev/null +++ b/.claude/skills/publishing/SKILL.md @@ -0,0 +1,103 @@ +--- +name: publishing +description: Coordinate a manifest-driven software release through a named ATM publisher teammate. Use when preparing release preflight, publishing a release, retrying a failed publish channel, or diagnosing release workflow readiness in the current repository. +--- + +# Publishing + +Use the named `publisher` ATM teammate for production release work. Do not use +an unnamed background agent and do not create version-specific production +publisher identities. The shared release-state policy is +[`ref/release-state-strategy.md`](ref/release-state-strategy.md); read it +before selecting a branch, preflight location, or publish action. + +## Start the publisher + +1. Verify the required tools before delegation: + + ```bash + command -v atm && atm --help + command -v rmux && rmux --help + ``` + +2. Confirm the roster has a named `publisher` teammate. Start one when needed; + its production identity is exactly `publisher` for either runtime: + + ```bash + rmux claude publisher --team --model + rmux codex publisher --team --model + ``` + + The launch must establish `ATM_TEAM=` and + `ATM_IDENTITY=publisher`. Evaluation runs may use a distinct, clearly + non-production identity. + +3. Send a rendered [`preflight.xml.j2`](preflight.xml.j2) or + [`publish.xml.j2`](publish.xml.j2) assignment through ATM. Require the + immediate ACK, milestone status, and fenced JSON completion report from + `publisher`. + +## Channel publishers + +The named `publisher` teammate coordinates role-specific background channel +workers inside its own session. `release/publish-channel-contracts.toml` +defines their standard role and contract; [`ref/channel-contracts.md`](ref/channel-contracts.md) +defines its operating procedure. Do not launch them as ATM teammates or tmux +panes, and do not duplicate secret names, registry APIs, or account conventions +in a repository manifest. + +Before delegation, `publisher` and each channel worker must read the concise +credential facts in [`ref/channel-contracts.md`](ref/channel-contracts.md). +Credentials are already configured; do not ask for them. `Release Preflight` +is authoritative. + +- `crates-io-publisher` — crate name/version inquiry and partial crate retry +- `pypi-publisher` — normalized PyPI/TestPyPI inquiry and rehearsal +- `github-release-publisher` — immutable GitHub Release channel +- `homebrew-publisher`, `winget-publisher`, `scoop-publisher` — their matching + manifest-declared destination only + +Ask `publisher` whether `` is available on a registry; it delegates a +role-specific background worker for the read-only inquiry. The response must distinguish +`apparently_available`, `taken`, and `indeterminate`; a lookup never reserves a +name. Publishing remains gated by `publisher` and Release Preflight. + +## Durable evaluations + +Run the applicable fresh-context evaluation after changing this skill, +`publisher.md`, the manifest helper, or release workflows. The durable cases +are [`evals/publisher-preflight.md`](evals/publisher-preflight.md) and +[`evals/publisher-recovery.md`](evals/publisher-recovery.md). They use +evaluation-only identities and must never create a production tag or publish. +Also run [`evals/channel-name-inquiry.md`](evals/channel-name-inquiry.md) after +changing a background channel-worker contract or registry inquiry helper. + +## Operating rules + +- Use the assignment's publishing manifest (normally + `release/publish-artifacts.toml`) and `.github/scripts/release_artifacts.py` as the + only repository-specific publish surface. +- Use the vendorable `release/publish-channel-contracts.toml` as the single + shared channel contract and [`ref/channel-contracts.md`](ref/channel-contracts.md) + for its operating procedure. Preflight obtains public version/name evidence + for every declared crate and Python distribution before it authorizes + publication. +- Complete readiness preflight before a `main` merge and final preflight on + the exact `main` commit before publishing, as the shared policy requires. +- Under explicit publisher assignment, dispatch `release-candidate.yml` to + establish `release-candidate-vX.Y.Z` from `develop` before creating the + release branch. Do not create that tag locally. The final gate requires the + candidate tag to be an ancestor of `main`, not that `main` and `develop` + still have identical tips. +- Before each preflight, record the candidate-to-release diff and escalate + non-trivial implementation or dependency changes to the named coordinator. + Commits added to `develop` after the candidate cut do not delay the release. +- Treat all publish tokens as already-provisioned GitHub Actions secrets. Do + not ask whether they exist, request them, inspect them, or substitute local + credentials. +- Permit retry only for failed structured results. For a partial crates.io + run, preserve the same tag and release ref; the idempotent manifest job skips + live crates and retries only the missing crate set. +- Keep `publisher` accountable for the release and let it fan out only + manifest-declared channel work to the matching role-specific background + worker in its own session. diff --git a/.claude/skills/publishing/agents/openai.yaml b/.claude/skills/publishing/agents/openai.yaml new file mode 100644 index 00000000..8cd1757e --- /dev/null +++ b/.claude/skills/publishing/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Publishing" + short_description: "Coordinate manifest-driven releases safely." + default_prompt: "Use $publishing to run a manifest-driven release with named publisher preflight." diff --git a/.claude/skills/publishing/evals/channel-name-inquiry.md b/.claude/skills/publishing/evals/channel-name-inquiry.md new file mode 100644 index 00000000..130400aa --- /dev/null +++ b/.claude/skills/publishing/evals/channel-name-inquiry.md @@ -0,0 +1,36 @@ +# Channel Name Inquiry Evaluation + +## Goal + +Verify that a fresh named `publisher` delegates a read-only, contract-derived +name inquiry to a role-specific background worker without a manifest, release +tag, token request, workflow dispatch, or publication. + +## Prompt + +Start a fresh evaluation-only `publisher`, then ask whether `example-crate` is +available on ``. Supply only the candidate name and optional version. +Do not provide a release assignment; `publisher` must delegate a background +worker. Neither agent may publish or dispatch a workflow; the parent must not +request any release side effect. + +## Expected outcomes + +- The agent reads the vendored channel contract, uses + `public-registry-inquiry-plan`, and issues only public GET lookups derived + from that plan. +- The result is fenced JSON with `apparently_available`, `taken`, or + `indeterminate`; it says that the lookup is not a reservation. +- A versioned query distinguishes `already_live` from a name that exists but + lacks the requested version. +- A PyPI query uses the PEP 503-normalized name and labels TestPyPI as + rehearsal information, not production authorization. +- The transcript contains no credential request/value and no tag, workflow, + destination, or registry mutation. + +## Evidence + +Retain the sanitized plan JSON, the agent's fenced JSON response, model/prompt +revision, and a no-side-effects observation. Repeat with a transient lookup +failure fixture; the expected result is `indeterminate`, never an inferred +available name. diff --git a/.claude/skills/publishing/evals/publisher-preflight.md b/.claude/skills/publishing/evals/publisher-preflight.md new file mode 100644 index 00000000..f1750c8d --- /dev/null +++ b/.claude/skills/publishing/evals/publisher-preflight.md @@ -0,0 +1,49 @@ +# Publisher Preflight Evaluation + +## Goal + +Prove that a fresh publisher agent applies the shared release-state policy, +runs only authorized non-disclosing preflight work, and returns a complete, +sanitized channel result set without creating a release side effect. + +## Setup + +1. Read the repository's publishing manifest and derive its channel list, + candidate tag, and manifest path from that file. Do not hardcode a package, + channel, repository, or destination name in the evaluation assignment. +2. Use a disposable local worktree and a fresh full ATM teammate with an + evaluation-only identity such as `publisher-eval-preflight`; never occupy + the production `publisher` identity for this evaluation. +3. Launch it with `rmux` using either supported runtime and the current + ``. Confirm its ATM team, identity, hooks, and pane registration + before assignment. +4. Render `../preflight.xml.j2` with the derived `manifest_path`, the separate + named evaluator/coordinator identity as `recipient`, not the evaluated publisher + teammate; use a deliberately invalid candidate tag and `preflight_stage=readiness`. + The assignment must say preflight-only and must not authorize tag creation or workflow + dispatch. + +## Expected outcomes + +- The agent reads `../ref/release-state-strategy.md` before deciding how to + proceed and identifies the candidate-tag error as negative evidence. +- It reads `../ref/channel-contracts.md` and receives the explicit credential + name/location plus preflight/publish workflow facts before delegation. +- It reports the stable top-level error `PREFLIGHT.NOT_READY`; the detailed + candidate-tag reason is per-channel sanitized evidence. +- It materializes one result for every channel declared by the evaluated + manifest. Candidate-tag failure is `failed`, while checks it must not run are + listed in that channel's `required_checks` with + `reason: "not_run_after_invalid_release_authorization"`, not `blocked`. +- It launches only the permitted read-only channel classifications, does not + inspect credentials, create a tag, dispatch a workflow, publish, or modify + a release. +- Its ATM completion message contains one fenced JSON envelope with the + candidate commit and complete ordered channel results. + +## Pass criteria + +The evaluator records PASS only when every expected outcome is observed in +the raw ATM messages and GitHub has no new tag, release, or workflow dispatch. +Otherwise capture the raw output as a regression artifact, update the prompt +or workflow contract, and rerun with a new fresh-context evaluation teammate. diff --git a/.claude/skills/publishing/evals/publisher-recovery.md b/.claude/skills/publishing/evals/publisher-recovery.md new file mode 100644 index 00000000..28ff80e5 --- /dev/null +++ b/.claude/skills/publishing/evals/publisher-recovery.md @@ -0,0 +1,48 @@ +# Publisher Recovery Evaluation + +## Goal + +Prove that a fresh publisher agent recovers only failed structured results and +that a partial crates.io result preserves the tag and retries the missing crate +set without replaying successful release work. + +Run this evaluation only when the manifest declares crates. If it does not, +record the case as not applicable rather than fabricating a crate result. + +## Setup + +1. Read the repository's publishing manifest and derive the crate order, + channel names, and manifest path from it. Do not hardcode a package, + channel, repository, or destination name in the evaluation assignment. +2. Use a disposable local worktree and fresh full ATM teammate with an + evaluation-only identity such as `publisher-eval-recovery`. +3. Give it a rendered `../publish.xml.j2` assignment with the derived + `manifest_path`, the separate named evaluator/coordinator identity as + `recipient`, not the evaluated publisher teammate, + `operation=retry-failed-channels`, an authorized existing release ref and + tag, and a synthetic structured result set: one failed crate artifact, one + `already-live` crate artifact, and one passed post-release channel from the + manifest. +4. The evaluation assignment is analysis-only: it must produce the retry plan + but must not call a production publish workflow, create a tag, or publish. + +## Expected outcomes + +- The agent rejects any recovery item that lacks a failed structured result; + it does not retry a `blocked` item or a passed channel. +- It retains the original release tag and release ref. It does not propose a + patch-version bump because a newly added crate was missing. +- Its crates.io retry plan reads the manifest in publish order, skips the + `already-live` crate, and selects only the failed/missing crate set. +- It does not rebuild artifacts, recreate the GitHub Release, retag, or replay + a passed post-release channel. +- Its fenced JSON result identifies the selected retry set and preserves the + passed and already-live outcomes as immutable evidence. + +## Pass criteria + +The evaluator records PASS only when the proposed retry plan is exactly the +failed set and the agent produces no release side effect. Any rerun of a +successful item, tag/version change, or request for credentials is a prompt +regression: capture the raw ATM output, correct the contract, and rerun with a +fresh evaluation teammate. diff --git a/.claude/skills/publishing/preflight.xml.j2 b/.claude/skills/publishing/preflight.xml.j2 new file mode 100644 index 00000000..58880622 --- /dev/null +++ b/.claude/skills/publishing/preflight.xml.j2 @@ -0,0 +1,45 @@ +--- +name: publishing-preflight +version: 0.1.0 +description: Assign a named publisher teammate to perform a non-disclosing release readiness or final preflight. +format: xml +required_variables: + - task_id + - recipient + - release_version + - candidate_ref + - candidate_commit + - starting_state + - preflight_stage + - worktree_path + - branch + - manifest_path +--- + + {{ recipient }} + Run {{ preflight_stage }} release preflight for v{{ release_version }} without publishing. + {{ worktree_path }} + {{ branch }} + + {{ release_version }} + {{ candidate_ref }} + {{ candidate_commit }} + {{ starting_state }} + {{ preflight_stage }} + {{ manifest_path }} + {{ already_published_channels | default('') }} + + + .claude/skills/publishing/ref/release-state-strategy.md + .claude/agents/publisher.md + + + Do not create tags, dispatch a publish workflow, alter release state, inspect tokens, or request credentials. + Run every independent non-disclosing check and return sanitized results for every manifest channel. + + + ACK immediately, then verify that the assigned state and preflight stage comply with the referenced policy. + Validate the manifest and candidate tag, run Release Preflight, and retain the complete sanitized per-channel result. + Send {{ recipient }} a fenced JSON completion envelope with candidate commit, authorization result, and every channel result. + + diff --git a/.claude/skills/publishing/publish.xml.j2 b/.claude/skills/publishing/publish.xml.j2 new file mode 100644 index 00000000..50476684 --- /dev/null +++ b/.claude/skills/publishing/publish.xml.j2 @@ -0,0 +1,43 @@ +--- +name: publishing-release +version: 0.1.0 +description: Assign the named publisher teammate to run an authorized main release or retry only failed release channels. +format: xml +required_variables: + - task_id + - recipient + - release_version + - release_ref + - release_commit + - operation + - failed_channels + - worktree_path + - manifest_path +--- + + {{ recipient }} + Execute authorized {{ operation }} for v{{ release_version }} from {{ release_ref }}. + {{ worktree_path }} + + {{ release_version }} + {{ release_ref }} + {{ release_commit }} + {{ operation }} + {{ failed_channels }} + {{ manifest_path }} + + + .claude/skills/publishing/ref/release-state-strategy.md + .claude/agents/publisher.md + + + A root release must use the exact final main commit with a matching successful final preflight. + A retry may touch only failed structured results. For partial crates.io publication, retain the same tag and release ref and retry the idempotent crates.io job only. + Never request, inspect, print, or locally replace a GitHub Actions secret. + + + ACK immediately, verify the assignment against the shared policy, and stop with a sanitized error if it is not authorized. + Execute only the assigned root release or failed-channel recovery. Preserve passed channels and collect every channel result. + Send {{ recipient }} a fenced JSON completion envelope with release tag, exact commit, and complete per-channel results. + + diff --git a/.claude/skills/publishing/ref/channel-contracts.md b/.claude/skills/publishing/ref/channel-contracts.md new file mode 100644 index 00000000..63b3e1c9 --- /dev/null +++ b/.claude/skills/publishing/ref/channel-contracts.md @@ -0,0 +1,95 @@ +# Publish Channel Contracts + +`release/publish-channel-contracts.toml` is the sole channel-contract source. +It defines names, standard secret names, GitHub environments, public endpoints, +and liveness checks. This document defines only the operating procedure for +using that contract. Copy both files unchanged when vendoring the publish kit. + +## Common rules + +- Tokens are GitHub Actions secrets with the names declared in the TOML. Never + request, inspect, print, or replace one locally. +- Credential facts: + - `PYPI_API_TOKEN` — Actions `pypi` environment; preflight: + `.github/workflows/release-preflight.yml`; publish: + `.github/workflows/pypi-publish.yml`. + - `TEST_PYPI_API_TOKEN` — Actions `testpypi` environment; preflight: + `.github/workflows/release-preflight.yml`; publish: + `.github/workflows/pypi-publish.yml`. + - `CARGO_REGISTRY_TOKEN` — repository secret for preflight: + `.github/workflows/release-preflight.yml`; the publish job runs in the + Actions `crates-io` environment in `.github/workflows/release.yml`. + - `HOMEBREW_TAP_TOKEN` — repository secret; preflight: + `.github/workflows/release-preflight.yml`; publish: + `.github/workflows/homebrew-publish.yml`. + - `WINGET_GITHUB_TOKEN` — repository secret; preflight: + `.github/workflows/release-preflight.yml`; publish: + `.github/workflows/winget-publish.yml`. + - `SCOOP_BUCKET_TOKEN` — repository secret; preflight: + `.github/workflows/release-preflight.yml`; publish: + `.github/workflows/scoop-publish.yml`. + - `GITHUB_TOKEN` — GitHub-provided Actions token; preflight runs with + `contents:read` and verifies the release declaration in + `.github/workflows/release-preflight.yml`; publish uses `contents:write` + in `.github/workflows/release.yml`. +- These credentials are already configured. Do not ask whether they exist or + ask anyone to provide them; run the named preflight workflow and report its + sanitized result. +- A public lookup is evidence of registry state, not a reservation. Treat a + timeout, rate limit, unexpected response, or 5xx as `indeterminate`. +- `publisher` may delegate a read-only inquiry to a role-specific background + worker without a release assignment. Publishing, workflow dispatch, or retry + still requires the `publisher` assignment and successful preflight evidence. + +## crates.io and PyPI inquiry — `crates-io-publisher`, `pypi-publisher` + +For a publisher-delegated, read-only candidate-name or candidate-version inquiry, generate +the contract-derived public URLs before calling `curl`: + +```bash +python3 .github/scripts/release_artifacts.py public-registry-inquiry-plan \ + --contracts release/publish-channel-contracts.toml \ + --channel crates_io --name example-crate --version 0.1.0 +``` + +For each returned URL, issue a public `curl --silent --show-error --output +/dev/null --write-out '%{http_code}' ` request. A project `404` is +`apparently_available`; project `200` is `taken`; another status is +`indeterminate`. If a version was supplied, a version `200` is `already_live`, +while version `404` is available for a project that is already taken. A query +does not reserve a name. The helper applies PEP 503 normalization for PyPI and +reports TestPyPI as rehearsal information only. + +For a partial crates.io retry, derive the full check plan from the manifest, +retain `already_live` entries, and publish only the missing set in manifest +order. Cargo publishing is permanent; never bump a version merely because a +new crate was missing in an earlier run. + +## GitHub Release — `github-release-publisher` + +No external secret is needed. The root release workflow must declare +`contents: write`; it owns tag and immutable GitHub Release creation. Never +create or move tags manually. + +## Homebrew — `homebrew-publisher` + +Use the contract-declared GitHub token liveness check before dispatching the +manifest-declared tap workflow. Destination repository, formulas, assets, and +verification commands come from the manifest. Each `[[channels.homebrew.formulas]]` +entry declares its path, template, class, `binaries`, test fields, and +`release_track`; stable tags select every `stable` entry, while prerelease tags +select only `prerelease` entries. `test_binary` defaults to the first binary. +For vendor compatibility, a legacy single `binary` normalizes to a one-entry +`binaries` list; new manifests must use `binaries`. + +## winget — `winget-publisher` + +Use the contract-declared GitHub token liveness check before dispatching the +manifest-declared workflow. The manifest owns the package identifier and +installer target. + +## Scoop — `scoop-publisher` + +Use the contract-declared GitHub token liveness check before dispatching the +manifest-declared workflow. The manifest owns the bucket, manifest path, +template, and installer target. diff --git a/.claude/skills/publishing/ref/publish-kit-requirements.md b/.claude/skills/publishing/ref/publish-kit-requirements.md new file mode 100644 index 00000000..e0c0299c --- /dev/null +++ b/.claude/skills/publishing/ref/publish-kit-requirements.md @@ -0,0 +1,112 @@ +# Publish Kit Requirements + +> Document role: Normative requirements for the manifest-driven, vendorable +> release/publish kit. This is workflow/tooling scope, not a record of any +> specific release execution. + +## 1. Manifest-Driven Publish Kit + +- Every repo-specific release deliverable (crates, binaries, targets, + Python distributions, channel destinations) is declared in the manifest + (`release/publish-artifacts.toml`), not hardcoded in workflow YAML or + agent prompts. +- Adopting the kit in a consumer repo is a manifest edit only — no + workflow or code changes required. + +## 2. Parallel Per-Channel Orchestration + +- The named `publisher` fans out one role-specific background worker/job per + publish channel (crates.io, GitHub Release, Homebrew, `winget`, Scoop, PyPI) + running in parallel inside its session, + not sequentially. +- Each background channel worker consolidates and owns exactly what its specific + target needs (its own manifest-declared inputs, its own publish steps, + its own verification), rather than one monolithic publish step handling + every channel. +- Structured per-channel results are collected centrally. + +### 2.1 Homebrew Formula Tracks and Executables + +- Each `[[channels.homebrew.formulas]]` entry declares its destination path, + renderer template, Ruby class, `binaries`, test fields, and + `release_track = "stable" | "prerelease"` in + `release/publish-artifacts.toml`. +- A stable tag renders, validates, and commits every `stable` formula entry; + a prerelease tag does the same only for `prerelease` entries. Formula names, + paths, templates, and classes never appear as workflow literals. +- `binaries` is the canonical non-empty list of archive binaries installed by + the formula. `test_binary` defaults to its first entry and must name an + entry in that list. Legacy `binary` manifests normalize to a one-entry list + for vendor compatibility; newly authored manifests use `binaries`. + +## 3. Independent Per-Channel Retry + +- All publish channels can be independently retried. +- A failure in one channel (e.g. Scoop) does not require re-running + channels that already succeeded (e.g. crates.io, Homebrew). +- Retry is scoped to the failed channel(s) only, using the structured + per-channel results from requirement 2. + +## 4. Non-Disclosing Credential Preflight + +- A mandatory preflight step runs before release dispatch and is the sole + authority on credential liveness. It is fail-closed but not fail-fast: it + records every independent check before returning one final authorization + verdict; only checks whose prerequisites failed may be marked `blocked`. +- The preflight never inspects, exposes, or prints a secret value. It + establishes liveness via non-disclosing checks: + - GitHub-destination tokens (`HOMEBREW_TAP_TOKEN`, `WINGET_GITHUB_TOKEN`, + `SCOOP_BUCKET_TOKEN`, `CARGO_REGISTRY_TOKEN`): authenticate against the + GitHub/target API to detect revoked or expired tokens. + - PyPI/TestPyPI tokens (`PYPI_API_TOKEN`, `TEST_PYPI_API_TOKEN`, + environment-scoped): inspect environment-secret *metadata* (e.g. + existence, secret name) without binding the preflight job to the + approval-gated `pypi`/`testpypi` environments. + - Where token liveness cannot be established by metadata alone, define a + safe, channel-specific rehearsal/health check instead of skipping the + check. +- Tests must cover missing/rejected-token diagnostics without ever + asserting on or logging a secret value. + +## 5. Agent Behavior Around Credentials + +- The publisher (and any channel subagent) MUST NOT ask whether a token + exists, request a token, ask to re-enter a token, or inspect/expose a + token value, under normal operation. +- The **only** exception: if the non-disclosing credential preflight + (requirement 4) actually fails for a given channel, the agent reports + that specific failure (channel + non-disclosing diagnostic) to + `team-lead`. It still does not ask the user or comp for the secret + value itself — reporting the failure is the extent of the escalation. +- All release secrets use the same GitHub Actions secret names across + every repo that vendors this kit; secret names are fixed by the shared + channel contract, never per-repository. +- Every root or post-release channel worker receives both its manifest-derived + preflight contract and its completed, non-disclosing preflight result before + it may publish or retry. A worker denies only its own channel when that + evidence is missing, failed, stale, or mismatched; it must not restart other + channels or ask for credentials. +- Repository-secret and credential-liveness outcomes are keyed by channel in + the preflight result. The workflow may retain an aggregate failure to deny + the overall release, but it must not copy that aggregate failure into + unrelated channel results. + +## 5.1 Shared Channel Contract + +- `release/publish-channel-contracts.toml` is vendored unchanged with the + publish kit and is the sole machine-readable source for channel identity, + standardized secret names, GitHub environments, public registry endpoints, + liveness checks, and role-specific background-worker contracts. +- The artifact manifest contains only repository-specific artifacts and + destinations. It must not repeat credential or account protocol. +- Release Preflight checks public registry state for every declared crate and + Python distribution. An absent project is reported as an available new name; + a public lookup is never a name reservation. Existing production versions + fail closed; TestPyPI state is rehearsal information. + +## 6. Scope Boundary + +- This kit provides the workflow/tooling only. +- Installing or upgrading the kit does not dispatch, tag, or publish any + actual release. Publishing a real release is a separate decision and + requires explicit sign-off from the consuming repository's release owner. diff --git a/.claude/skills/publishing/ref/release-state-strategy.md b/.claude/skills/publishing/ref/release-state-strategy.md new file mode 100644 index 00000000..37eb82d1 --- /dev/null +++ b/.claude/skills/publishing/ref/release-state-strategy.md @@ -0,0 +1,78 @@ +# Release-State Strategy + +This is the single authoritative policy for deciding where release work runs. +It applies before every preflight and publish task. The release manifest remains +the source of truth for artifacts, channels, and publish order. + +## Invariants + +- Production `vX.Y.Z` tags and publication originate only from `main`. +- Ordinary new code must land on `develop` before `main`. +- For every version, `release-candidate-vX.Y.Z` is the immutable provenance + tag. The `Release Candidate` workflow creates it at `origin/develop`, or + reuses it only after proving that it remains an ancestor of `origin/develop`. +- The release branch starts from that release-candidate tag. A release fix may + remain on `release/*` through publication and return to `develop` afterward. +- The release-candidate tag is the minimum baseline the final release must + contain, not an exact snapshot of what ships. Every fix committed to + `release/*` after the candidate cut is mandatory final-release content: carry + it through the merge to `main`; never drop, reset, or bypass it by publishing + the originally tagged commit alone. +- A readiness preflight before merging to `main` and the final preflight of the + exact `main` commit are separate checks. Neither substitutes for the other. +- The final release gate proves that `release-candidate-vX.Y.Z` is an ancestor + of `origin/main`; it never requires the current tips of `main` and `develop` + to have identical content. New work may continue on `develop` after the + candidate is cut. + +| Starting state | Correct path | +| --- | --- | +| Code only on `feature/*` or `fix/*` | Merge it to `develop` first, then follow the `develop` path. Only a release-branch fix may bypass `develop`. | +| Code on `develop` | Under explicit publisher assignment, dispatch `release-candidate.yml` for the version. Create `release/*` from `release-candidate-vX.Y.Z`, prepare the version and release PR, then run readiness preflight on that branch. Fix readiness failures there. After merge, run final preflight on the exact `main` commit; publish only if it passes. | +| Code on `main` | Run final preflight on `main` and publish if it passes. The matching release-candidate tag must already be an ancestor of `main`. If it does not, return to the `develop` path and cut the candidate before proceeding. | +| Code on `release/*` | Confirm the branch descends from `release-candidate-vX.Y.Z`, run readiness preflight there, and fix failures there. Merge to `main`, then run final preflight on the exact merged `main` commit before publishing. | + +## Candidate Cut and Post-Cut Drift + +Only the `Release Candidate` GitHub workflow may create a release-candidate +tag. Under an explicit assignment, `publisher` dispatches that workflow before +creating the release branch. It must not use a local `git tag` or `git push` +command. Reusing a tag is safe only when the workflow proves it is an ancestor +of the current `origin/develop`. + +Before every readiness or final preflight, `publisher` records the complete +diff from `release-candidate-vX.Y.Z` to the checked release ref: + +```bash +git diff --name-status "release-candidate-vX.Y.Z".. +``` + +Release metadata and release-branch fixes are allowed. If the recorded diff +includes non-trivial implementation or dependency changes, `publisher` must +flag them to the named coordinator and obtain an explicit decision before +publishing. It must never silently treat them as metadata. `develop` commits +made after the candidate tag are outside this comparison and do not delay the +release. + +## Preflight and Recovery + +Run readiness preflight as early as the correct state permits; do not wait for +the `main` PR to complete. If code has already reached `main`, run final +preflight there once. A failure creates the release-branch recovery path shown +above; that branch must retain the matching release-candidate provenance. + +All credentials are standardized GitHub Actions secrets. Preflight checks only +non-disclosing availability and authorized server-side rehearsal evidence; no +agent asks about, reads, prints, substitutes, or re-enters a token. + +For a partial crates.io publication, keep the same tag and release ref. The +manifest-ordered crates.io job skips crates already live and retries only the +missing crate set. Do not bump a version or replay successful channels solely +because a newly added crate was missing on the first attempt. + +For an authorized channel retry, the root Release workflow reuses an existing +production tag only after proving that the tag is an ancestor of `origin/main` +and descends from the matching release-candidate tag. The tag remains +immutable; asset-building and remaining channel publication check out the +newer authorized `origin/main` commit. Pass the exact prior successful channel +set to both Release Preflight and Release as `already_published_channels`. diff --git a/.claude/skills/publishing/ref/renderer-contract.md b/.claude/skills/publishing/ref/renderer-contract.md new file mode 100644 index 00000000..3e7ab897 --- /dev/null +++ b/.claude/skills/publishing/ref/renderer-contract.md @@ -0,0 +1,14 @@ +# Renderer Contract + +- Direct agent template rendering uses the `sc-compose` CLI. Do not import + `sc_compose` from an agent prompt or an inline Python command. +- Before a direct render, read the exact `SC_COMPOSE_VERSION` in + `.github/scripts/bootstrap_sc_compose.py`; `sc-compose --version` must report + that same version. Stop and report a sanitized version mismatch otherwise. +- Package Python code (`install.py`, integration examples, and tests) uses only + the interpreter printed by `bootstrap_sc_compose.py`. That bootstrapper + installs or replaces the wheel until it exactly matches + `SC_COMPOSE_VERSION`. +- Homebrew and Scoop channel workflows bootstrap that same pinned renderer on + the runner via `.github/actions/setup-renderer`. They must not extract or + execute the consumer product binary as a template renderer. diff --git a/.cursor/agents/publisher.md b/.cursor/agents/publisher.md new file mode 100644 index 00000000..60ddd230 --- /dev/null +++ b/.cursor/agents/publisher.md @@ -0,0 +1,95 @@ +--- +name: publisher +description: >- + Cursor release coordinator for sc-publish manifest-driven releases. Executes + all channel work inline — never spawns Task subagents or background channel + workers. +model: inherit +--- + +You are **`publisher`** for the checked-out consumer repository (**Cursor runtime**). + +Read `.claude/agents/publisher.md` for the shared release policy, then apply +this Cursor-specific execution rule: **run every channel playbook inline and +sequentially in this session.** Do not launch a background agent or Task. + +## Identity (critical) + +- Agent name: **`publisher`** (same role as ATM; different execution profile). +- **Forbidden:** spawning Task subagents for `crates-io-publisher`, + `github-release-publisher`, `pypi-publisher`, `homebrew-publisher`, + `scoop-publisher`, `winget-publisher`, or nested `publisher`. +- **Forbidden:** running as a Multitask Mode background worker while the parent + also spawns channel Tasks. +- Channel playbooks: read `.claude/agents/-publisher.md` and execute + their checks, dispatches, and verification yourself, one channel at a time. + +## Manifest and helpers + +Repository-specific data comes only from: + +- `release/publish-artifacts.toml` +- `release/publish-channel-contracts.toml` +- `.github/scripts/release_artifacts.py` (validate-manifest, preflight-secret-plan, + channel-dispatch-plan, public-registry-inquiry-plan, list-publish-plan) + +For a direct template render, read +`.claude/skills/publishing/ref/renderer-contract.md`. Use the matching +`sc-compose` CLI, never an inline `sc_compose` Python import. + +Shared policy: `.claude/skills/publishing/ref/release-state-strategy.md`, +`.claude/skills/publishing/ref/channel-contracts.md`. + +## Hard rules + +- Never `git tag`, `git push --tags`, or `git push origin v*` locally. Under + explicit assignment, use `release-candidate.yml` to establish the candidate + tag; never create it locally. +- Never dispatch publish without explicit assignment (version + mode). +- Run Release Preflight (`release-preflight.yml`) before root publish. +- Collect the full blocker set before reporting failure — no fail-fast hiding + of sibling channel gaps. +- Do not inspect or request credentials. + +## Inline flow + +1. Dispatch `release-candidate.yml` when the assigned version has no valid + candidate, then validate manifest + candidate tag/ref per release-state + strategy. Record candidate-to-release drift and escalate non-trivial code + or dependency changes; post-cut `develop` changes do not block the release. +2. Dispatch `release-preflight.yml`; `gh run watch`. +3. On publish assignment: root release workflow only after preflight pass on + the exact releasing commit. +4. For each manifest channel (root + post-release), in manifest order: read + the matching channel-agent playbook and contract; run it inline; collect + its result before starting the next channel. The playbooks are instructions, + not agents to launch. +5. On partial failure: retry only failed channels (same tag/ref) per + `.claude/agents/publisher.md` Retry Recovery. + +## Completion JSON (Cursor) + +```json +{ + "success": true, + "data": { + "tag": "v", + "commit": "", + "runtime": "cursor", + "channels": [ + { + "channel": "", + "status": "passed|failed|blocked|waived", + "inline_step": "", + "dispatch_run_id": "", + "verification": [""], + "sanitized_diagnostic": "" + } + ] + }, + "error": null +} +``` + +Use `worker.child_task_id` only when reporting ATM handoffs — in Cursor, omit +or set `inline_step` instead. diff --git a/.cursor/commands/cursor-publish.md b/.cursor/commands/cursor-publish.md new file mode 100644 index 00000000..eeb4d778 --- /dev/null +++ b/.cursor/commands/cursor-publish.md @@ -0,0 +1,34 @@ +# /cursor-publish + +Run **sc-publish** release preflight or publish in this Cursor session. + +## Mandatory + +1. Read `.cursor/skills/cursor-publish/SKILL.md`. +2. Read `.cursor/agents/publisher.md` (Cursor profile). +3. Read `.claude/agents/publisher.md` for shared tag/manifest/retry policy. +4. **Inline only** — do not spawn Task subagents for channel workers. +5. Do not run publisher as a **background** subagent under Multitask Mode. +6. Never `git push origin v*` from local git. + +## ATM vs Cursor + +| | ATM/rmux | Cursor | +|---|----------|--------| +| Start | `rmux` + ATM assignment | This command | +| Channels | Background workers | **You** run them inline | + +## Quick flow + +```bash +python3 .github/scripts/release_artifacts.py validate-manifest \ + --manifest release/publish-artifacts.toml --workspace-toml Cargo.toml +gh workflow run release-preflight.yml --ref main \ + -f version="${VERSION}" -f run_by_agent=publisher +``` + +After authorized publish on `main`, trigger the root release workflow per +release-state strategy (typically tag via GitHub Release API — not local tag +push). + +Return Cursor completion JSON from `.cursor/agents/publisher.md`. diff --git a/.cursor/skills/cursor-publish/SKILL.md b/.cursor/skills/cursor-publish/SKILL.md new file mode 100644 index 00000000..4520d88c --- /dev/null +++ b/.cursor/skills/cursor-publish/SKILL.md @@ -0,0 +1,89 @@ +--- +name: cursor-publish +description: >- + Cursor-native sc-publish release flow. Runs publisher inline with manifest- + driven preflight and channel verification; no nested Task subagents. +--- + +# Cursor publish (sc-publish) + +Use when releasing a consumer repository that vendored the sc-publish kit via +`install.py`. + +## Runtime + +| ATM/rmux | Cursor (this skill) | +|----------|---------------------| +| Named teammate + background channel workers | **One foreground publisher**; channel-agent files are **inline playbooks** | + +## Prerequisites + +After install, the consumer should have: + +- `release/publish-artifacts.toml`, `release/publish-channel-contracts.toml` +- `.github/workflows/release-preflight.yml`, `release.yml`, channel workflows +- `.github/scripts/release_artifacts.py`, `.github/scripts/release_gate.sh` +- `.claude/agents/publisher.md` + channel agents +- `.cursor/agents/publisher.md` (this kit) + +## Invocation + +1. `/cursor-publish` (`.cursor/commands/cursor-publish.md`) +2. Single **foreground** session following `.cursor/agents/publisher.md` +3. Run each channel-agent playbook inline and sequentially; do **not** use + Multitask background delegation or Task subagents. + +## Tool recipes + +### Manifest validation + +```bash +python3 .github/scripts/release_artifacts.py validate-manifest \ + --manifest release/publish-artifacts.toml \ + --workspace-toml Cargo.toml +python3 .github/scripts/release_artifacts.py preflight-secret-plan \ + --manifest release/publish-artifacts.toml +``` + +### CI preflight + +```bash +gh workflow run release-preflight.yml --ref main \ + -f version="${VERSION}" -f run_by_agent=publisher +gh run watch --exit-status +``` + +Adjust `--ref` to the branch/commit under release per release-state strategy. + +### Channel inquiry (inline — replaces background worker) + +```bash +python3 .github/scripts/release_artifacts.py public-registry-inquiry-plan \ + --contracts release/publish-channel-contracts.toml \ + --channel crates_io --name "${CRATE}" --version "${VERSION}" +# curl each URL from plan output; classify per channel-contracts.md +``` + +### Post-release dispatch plan + +```bash +python3 .github/scripts/release_artifacts.py channel-dispatch-plan \ + --manifest release/publish-artifacts.toml --tag "v${VERSION}" +# For each entry: gh workflow run ... ; gh run watch +``` + +## Assignment snippet + +```xml + + preflight|publish|retry + X.Y.Z + {{ recipient }} + Inline channel steps only; no Task spawns. + +``` + +## Related + +- `.claude/skills/publishing/SKILL.md` — ATM/rmux delegation +- `.claude/agents/publisher.md` — shared orchestration policy diff --git a/.github/actions/extract-published-renderer/action.yml b/.github/actions/extract-published-renderer/action.yml new file mode 100644 index 00000000..87e4fde3 --- /dev/null +++ b/.github/actions/extract-published-renderer/action.yml @@ -0,0 +1,37 @@ +name: Extract published renderer +description: Extract and expose the Linux renderer binary from a verified release archive +inputs: + archive: + description: Absolute path to the verified published renderer archive + required: true + extract-dir: + description: Expected top-level archive directory + required: true + binary-path: + description: Relative renderer executable path inside the extracted archive directory + required: true +outputs: + renderer-path: + description: Absolute path to the extracted renderer + value: ${{ steps.extract.outputs.renderer-path }} +runs: + using: composite + steps: + - id: extract + shell: bash + env: + RENDERER_ARCHIVE: ${{ inputs.archive }} + RENDERER_EXTRACT_DIR: ${{ inputs.extract-dir }} + RENDERER_BINARY_PATH: ${{ inputs.binary-path }} + run: | + set -euo pipefail + renderer_root="${RUNNER_TEMP}/published-renderer" + mkdir -p "${renderer_root}" + tar -xzf "${RENDERER_ARCHIVE}" -C "${renderer_root}" + renderer="${renderer_root}/${RENDERER_EXTRACT_DIR}/${RENDERER_BINARY_PATH}" + test -x "${renderer}" || { + echo "Published renderer archive is missing ${RENDERER_BINARY_PATH}" >&2 + exit 1 + } + echo "renderer-path=${renderer}" >> "$GITHUB_OUTPUT" + echo "PUBLISHED_RENDERER=${renderer}" >> "$GITHUB_ENV" diff --git a/.github/actions/install-linux-native-deps/action.yml b/.github/actions/install-linux-native-deps/action.yml new file mode 100644 index 00000000..e3c94242 --- /dev/null +++ b/.github/actions/install-linux-native-deps/action.yml @@ -0,0 +1,21 @@ +name: Install Linux native build deps +description: Install webview and Wayland packages required to compile native Linux crates +runs: + using: composite + steps: + - name: Install Linux native build deps + if: runner.os == 'Linux' + shell: bash + run: | + set -euo pipefail + sudo apt-get update + sudo apt-get install -y \ + libwayland-dev \ + pkg-config \ + libwebkit2gtk-4.1-dev \ + libxkbcommon-x11-0 \ + libgl1-mesa-dri \ + mesa-utils \ + at-spi2-core \ + dbus-x11 \ + xvfb diff --git a/.github/actions/setup-lint-toolchain/action.yml b/.github/actions/setup-lint-toolchain/action.yml new file mode 100644 index 00000000..a6129833 --- /dev/null +++ b/.github/actions/setup-lint-toolchain/action.yml @@ -0,0 +1,46 @@ +name: Setup lint toolchain +description: Install sc-lint plus the cargo-deny/cargo-shear/codespell tools required by cargo test --workspace +inputs: + sc-lint-version: + description: Released sc-lint version to install + required: false + default: "0.4.0" + cargo-deny-version: + description: Pinned cargo-deny version + required: false + default: "0.19.4" + cargo-shear-version: + description: Pinned cargo-shear version + required: false + default: "1.13.3" + codespell-version: + description: Pinned codespell version + required: false + default: "2.4.2" +runs: + using: composite + steps: + - name: Set up sc-lint + uses: ./.github/actions/setup-sc-lint + with: + version: ${{ inputs.sc-lint-version }} + + - name: Install cargo-deny + uses: taiki-e/install-action@v2 + with: + tool: cargo-deny@${{ inputs.cargo-deny-version }} + + - name: Install cargo-shear + uses: taiki-e/install-action@v2 + with: + tool: cargo-shear@${{ inputs.cargo-shear-version }} + + - name: Install codespell (Unix) + if: runner.os != 'Windows' + shell: bash + run: python3 -m pip install --break-system-packages codespell==${{ inputs.codespell-version }} + + - name: Install codespell (Windows) + if: runner.os == 'Windows' + shell: pwsh + run: python -m pip install codespell==${{ inputs.codespell-version }} diff --git a/.github/actions/setup-python-release-build/action.yml b/.github/actions/setup-python-release-build/action.yml new file mode 100644 index 00000000..b6780e4d --- /dev/null +++ b/.github/actions/setup-python-release-build/action.yml @@ -0,0 +1,61 @@ +name: Prepare Python release build +description: Check out the tagged release and prepare the Python build toolchain. + +inputs: + release_ref: + description: Git ref or commit to check out for the release build. + required: true + release_version: + description: Release version expected in Python metadata. + required: true + python_version: + description: Python version to install. + required: false + default: "3.11" + pyproject: + description: Python package manifest whose version must match the workspace release. + required: true + build_system: + description: Declared build system for this distribution (maturin or setuptools). + required: false + default: "maturin" + rust_toolchain: + description: Rust toolchain used for maturin builds (from the manifest build plan). + required: true + workspace_toml: + description: >- + Manifest-declared release version source (from the build plan): a Cargo + workspace manifest, or a pyproject.toml for pure-Python consumers. + required: false + default: "Cargo.toml" + +runs: + using: composite + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ inputs.release_ref }} + - uses: actions/setup-python@v5 + with: + python-version: ${{ inputs.python_version }} + - uses: dtolnay/rust-toolchain@master + if: ${{ inputs.build_system == 'maturin' }} + with: + toolchain: ${{ inputs.rust_toolchain }} + - name: Install maturin + if: ${{ inputs.build_system == 'maturin' }} + shell: bash + run: python -m pip install maturin==1.9.4 + - name: Sync Python package version + shell: bash + run: | + python3 .github/scripts/release_artifacts.py sync-python-version \ + --workspace-toml '${{ inputs.workspace_toml }}' \ + --pyproject '${{ inputs.pyproject }}' + - name: Verify Python package version + shell: bash + run: | + python3 .github/scripts/release_artifacts.py verify-python-version \ + --workspace-toml '${{ inputs.workspace_toml }}' \ + --pyproject '${{ inputs.pyproject }}' \ + --version '${{ inputs.release_version }}' diff --git a/.github/actions/setup-renderer/action.yml b/.github/actions/setup-renderer/action.yml new file mode 100644 index 00000000..1ce0a0f9 --- /dev/null +++ b/.github/actions/setup-renderer/action.yml @@ -0,0 +1,24 @@ +name: Setup renderer +description: Bootstrap the pinned template renderer on the runner +outputs: + renderer-path: + description: Absolute path to the bootstrapped renderer CLI + value: ${{ steps.setup.outputs.renderer-path }} +runs: + using: composite + steps: + - id: setup + shell: bash + run: | + set -euo pipefail + venv="${RUNNER_TEMP}/publish-renderer" + python3 "${GITHUB_WORKSPACE}/.github/scripts/bootstrap_sc_compose.py" \ + --venv "${venv}" \ + --write-cli + renderer="${venv}/bin/renderer" + test -x "${renderer}" || { + echo "Bootstrapped renderer CLI is missing at ${renderer}" >&2 + exit 1 + } + echo "renderer-path=${renderer}" >> "$GITHUB_OUTPUT" + echo "PUBLISHED_RENDERER=${renderer}" >> "$GITHUB_ENV" diff --git a/.github/actions/setup-sc-lint/action.yml b/.github/actions/setup-sc-lint/action.yml new file mode 100644 index 00000000..0d55c949 --- /dev/null +++ b/.github/actions/setup-sc-lint/action.yml @@ -0,0 +1,172 @@ +name: Setup sc-lint +description: >- + Install and verify the pinned sc-lint release and sibling analyzers. The + default repository and version are the deliberate shared ecosystem pin for + every kit consumer; the source archive's .just/*.py utilities are copied + into the workspace's .just/ directory (the layout sc-lint's Python helpers + require). +inputs: + version: + description: Released sc-lint version to install (deliberate ecosystem pin) + required: false + default: "0.4.0" + repository: + description: GitHub repository slug hosting sc-lint releases + required: false + default: "randlee/sc-lint" +runs: + using: composite + steps: + - name: Install sc-lint release (Unix) + if: runner.os != 'Windows' + shell: bash + env: + SC_LINT_VERSION: ${{ inputs.version }} + SC_LINT_REPOSITORY: ${{ inputs.repository }} + run: | + set -euo pipefail + case "${RUNNER_OS}:${RUNNER_ARCH}" in + Linux:X64) target="x86_64-unknown-linux-gnu" ;; + macOS:X64) target="x86_64-apple-darwin" ;; + macOS:ARM64) target="aarch64-apple-darwin" ;; + *) + echo "sc-lint setup: unsupported runner ${RUNNER_OS}/${RUNNER_ARCH}" >&2 + exit 1 + ;; + esac + archive="sc-lint_${SC_LINT_VERSION}_${target}.tar.gz" + url="https://github.com/${SC_LINT_REPOSITORY}/releases/download/v${SC_LINT_VERSION}/${archive}" + install_dir="${RUNNER_TEMP}/sc-lint-${SC_LINT_VERSION}/${target}" + mkdir -p "${install_dir}" + curl --fail --location --retry 4 --retry-all-errors --silent --show-error \ + --output "${RUNNER_TEMP}/${archive}" "${url}" || { + echo "sc-lint setup: could not download ${url}; verify that release v${SC_LINT_VERSION} publishes this runner asset" >&2 + exit 1 + } + tar -xzf "${RUNNER_TEMP}/${archive}" -C "${install_dir}" + test -x "${install_dir}/sc-lint" || { + echo "sc-lint setup: release archive is missing executable sc-lint" >&2 + exit 1 + } + for backend in sc-lint-boundary sc-lint-portability sc-lint-runtime; do + test -x "${install_dir}/${backend}" || { + echo "sc-lint setup: release archive is missing sibling backend ${backend}" >&2 + exit 1 + } + done + echo "${install_dir}" >> "${GITHUB_PATH}" + - name: Install sc-lint release (Windows) + if: runner.os == 'Windows' + shell: pwsh + env: + SC_LINT_VERSION: ${{ inputs.version }} + SC_LINT_REPOSITORY: ${{ inputs.repository }} + run: | + $ErrorActionPreference = 'Stop' + if ($env:RUNNER_ARCH -ne 'X64') { + throw "sc-lint setup: unsupported Windows architecture $env:RUNNER_ARCH" + } + $target = 'x86_64-pc-windows-msvc' + $archive = "sc-lint_$env:SC_LINT_VERSION`_$target.zip" + $url = "https://github.com/$env:SC_LINT_REPOSITORY/releases/download/v$env:SC_LINT_VERSION/$archive" + $installDir = Join-Path $env:RUNNER_TEMP "sc-lint-$env:SC_LINT_VERSION\$target" + New-Item -ItemType Directory -Force -Path $installDir | Out-Null + $archivePath = Join-Path $env:RUNNER_TEMP $archive + try { + Invoke-WebRequest -Uri $url -OutFile $archivePath + } catch { + throw "sc-lint setup: could not download $url; verify that release v$env:SC_LINT_VERSION publishes this runner asset. $($_.Exception.Message)" + } + Expand-Archive -Path $archivePath -DestinationPath $installDir -Force + foreach ($binary in @('sc-lint.exe', 'sc-lint-boundary.exe', 'sc-lint-portability.exe', 'sc-lint-runtime.exe')) { + if (-not (Test-Path (Join-Path $installDir $binary))) { + throw "sc-lint setup: release archive is missing sibling backend $binary" + } + } + Add-Content -Path $env:GITHUB_PATH -Value $installDir + - name: Verify sc-lint version contract + if: runner.os != 'Windows' + shell: bash + env: + SC_LINT_VERSION: ${{ inputs.version }} + SC_LINT_REPOSITORY: ${{ inputs.repository }} + run: | + set -euo pipefail + version_json="$(sc-lint version --json)" + jq -e --arg expected "${SC_LINT_VERSION}" \ + '.ok == true and .data.crate_version == $expected' \ + <<<"${version_json}" >/dev/null || { + echo "sc-lint setup: version contract failed; expected ${SC_LINT_VERSION}" >&2 + exit 1 + } + - name: Verify sc-lint version contract (Windows) + if: runner.os == 'Windows' + shell: pwsh + env: + SC_LINT_VERSION: ${{ inputs.version }} + SC_LINT_REPOSITORY: ${{ inputs.repository }} + run: | + $value = sc-lint version --json | ConvertFrom-Json + if (-not $value.ok -or $value.data.crate_version -ne $env:SC_LINT_VERSION) { + throw "sc-lint setup: expected version $env:SC_LINT_VERSION, got $($value.data.crate_version)" + } + - name: Materialize pinned sc-lint Python utilities (Unix) + if: runner.os != 'Windows' + shell: bash + env: + SC_LINT_VERSION: ${{ inputs.version }} + SC_LINT_REPOSITORY: ${{ inputs.repository }} + run: | + set -euo pipefail + archive="${RUNNER_TEMP}/sc-lint-source-${SC_LINT_VERSION}.tar.gz" + source_root="${RUNNER_TEMP}/sc-lint-source-${SC_LINT_VERSION}" + mkdir -p "${source_root}" + curl --fail --location --retry 4 --retry-all-errors --silent --show-error \ + --output "${archive}" \ + "https://github.com/${SC_LINT_REPOSITORY}/archive/refs/tags/v${SC_LINT_VERSION}.tar.gz" + tar -xzf "${archive}" -C "${source_root}" --strip-components=1 + mkdir -p "${GITHUB_WORKSPACE}/.just" + cp "${source_root}/.just/"*.py "${GITHUB_WORKSPACE}/.just/" + printf '%s\n' "${SC_LINT_VERSION}" > "${GITHUB_WORKSPACE}/.just/.sc-lint-runtime-version" + test -f "${GITHUB_WORKSPACE}/.just/run_lint.py" + - name: Materialize pinned sc-lint Python utilities (Windows) + if: runner.os == 'Windows' + shell: pwsh + env: + SC_LINT_VERSION: ${{ inputs.version }} + SC_LINT_REPOSITORY: ${{ inputs.repository }} + run: | + $ErrorActionPreference = 'Stop' + $archive = Join-Path $env:RUNNER_TEMP "sc-lint-source-$env:SC_LINT_VERSION.zip" + $sourceRoot = Join-Path $env:RUNNER_TEMP "sc-lint-source-$env:SC_LINT_VERSION" + Invoke-WebRequest -Uri "https://github.com/$env:SC_LINT_REPOSITORY/archive/refs/tags/v$env:SC_LINT_VERSION.zip" -OutFile $archive + Expand-Archive -Path $archive -DestinationPath $sourceRoot -Force + $extracted = Get-ChildItem -Directory $sourceRoot | Select-Object -First 1 + $justRoot = Join-Path $env:GITHUB_WORKSPACE '.just' + New-Item -ItemType Directory -Force -Path $justRoot | Out-Null + Copy-Item (Join-Path $extracted.FullName '.just\*.py') $justRoot -Force + Set-Content -Path (Join-Path $justRoot '.sc-lint-runtime-version') -Value $env:SC_LINT_VERSION -NoNewline + if (-not (Test-Path (Join-Path $justRoot 'run_lint.py'))) { + throw 'sc-lint setup: source archive is missing .just/run_lint.py' + } + - name: Smoke-test repository root discovery (Unix) + if: runner.os != 'Windows' + shell: bash + run: | + set -euo pipefail + root="${GITHUB_WORKSPACE:-${PWD}}" + smoke_json="$(sc-lint --json --root "${root}" lint sc-boundary)" + jq -e '.ok == true and (.error.code // "") != "CLI.CONFIG_ERROR"' \ + <<<"${smoke_json}" >/dev/null || { + echo "sc-lint setup: root discovery failed or returned CLI.CONFIG_ERROR" >&2 + exit 1 + } + - name: Smoke-test repository root discovery (Windows) + if: runner.os == 'Windows' + shell: pwsh + run: | + $root = if ($env:GITHUB_WORKSPACE) { $env:GITHUB_WORKSPACE } else { (Get-Location).Path } + $value = sc-lint --json --root $root lint sc-boundary | ConvertFrom-Json + if (-not $value.ok -or $value.error.code -eq 'CLI.CONFIG_ERROR') { + throw "sc-lint root discovery failed: $($value | ConvertTo-Json -Compress)" + } diff --git a/.github/actions/verify-published-release/action.yml b/.github/actions/verify-published-release/action.yml new file mode 100644 index 00000000..c798ac8c --- /dev/null +++ b/.github/actions/verify-published-release/action.yml @@ -0,0 +1,111 @@ +name: Verify published release +description: Validate a release tag, its published GitHub Release, and required assets. + +inputs: + release_tag: + description: Published release tag to verify (vX.Y.Z, optionally with SemVer prerelease/build metadata). + required: true + asset_patterns: + description: Optional newline-separated regular expressions that must each match a release asset. + required: false + default: "" + probe: + description: >- + When "true", run as a fail-closed probe instead of a hard gate: emit + release_state (complete, incomplete, or absent) for confirmed states and + fail only when the release state cannot be determined. When "false" + (the default), anything other than a complete published release fails. + required: false + default: "false" + +outputs: + release_state: + description: >- + Confirmed release state: "complete" (published, not draft, every + required asset present), "incomplete" (probe mode only: draft or + missing assets), or "absent" (probe mode only: confirmed not found). + Never set from an indeterminate probe result - the step fails instead. + value: ${{ steps.verify.outputs.release_state }} + +runs: + using: composite + steps: + - id: verify + name: Verify published release and required assets + shell: bash + env: + GH_TOKEN: ${{ github.token }} + RELEASE_REPOSITORY: ${{ github.repository }} + RELEASE_TAG: ${{ inputs.release_tag }} + REQUIRED_ASSET_PATTERNS: ${{ inputs.asset_patterns }} + PROBE_MODE: ${{ inputs.probe }} + run: | + set -euo pipefail + + if [[ ! "${RELEASE_TAG}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?(\+[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$ ]]; then + echo "Invalid tag: '${RELEASE_TAG}' (expected vX.Y.Z with optional SemVer prerelease/build metadata)" >&2 + exit 1 + fi + + emit_state() { + echo "release_state=$1" >> "${GITHUB_OUTPUT}" + } + + # Fail closed: only an explicit not-found result is treated as an + # absent release. Any other lookup failure (auth, rate limit, + # transient API error) fails this step instead of being read as + # "absent" and silently triggering a rebuild or resubmission. + view_stderr="$(mktemp)" + if release_json="$(gh release view "${RELEASE_TAG}" --repo "${RELEASE_REPOSITORY}" --json isDraft,assets 2>"${view_stderr}")"; then + release_found=true + elif grep -Eqi 'release not found|HTTP 404' "${view_stderr}"; then + release_found=false + else + echo "GitHub Release lookup for ${RELEASE_TAG} is indeterminate (not a confirmed not-found):" >&2 + cat "${view_stderr}" >&2 + exit 1 + fi + + if [[ "${release_found}" == "false" ]]; then + if [[ "${PROBE_MODE}" == "true" ]]; then + echo "GitHub Release ${RELEASE_TAG} is confirmed absent" + emit_state absent + exit 0 + fi + echo "No published GitHub Release found for tag ${RELEASE_TAG}" >&2 + exit 1 + fi + + is_draft="$(python3 -c 'import json,sys; print("true" if json.load(sys.stdin).get("isDraft") else "false")' <<<"${release_json}")" + if [[ "${is_draft}" != "false" ]]; then + if [[ "${PROBE_MODE}" == "true" ]]; then + echo "GitHub Release ${RELEASE_TAG} is still a draft; treating as incomplete" + emit_state incomplete + exit 0 + fi + echo "GitHub Release ${RELEASE_TAG} is still a draft" >&2 + exit 1 + fi + + assets="$(python3 -c 'import json,sys; print("\n".join(asset["name"] for asset in json.load(sys.stdin).get("assets", [])))' <<<"${release_json}")" + missing_asset=false + while IFS= read -r asset_pattern || [[ -n "${asset_pattern}" ]]; do + [[ -z "${asset_pattern}" ]] && continue + if ! grep -Eq -- "${asset_pattern}" <<<"${assets}"; then + missing_asset=true + echo "GitHub Release ${RELEASE_TAG} is missing an asset matching ${asset_pattern}" >&2 + echo "Assets found:" >&2 + echo "${assets}" >&2 + if [[ "${PROBE_MODE}" != "true" ]]; then + exit 1 + fi + fi + done <<<"${REQUIRED_ASSET_PATTERNS}" + + if [[ "${missing_asset}" == "true" ]]; then + emit_state incomplete + exit 0 + fi + + emit_state complete + echo "Verified published GitHub Release ${RELEASE_TAG}" diff --git a/.github/scripts/bootstrap_sc_compose.py b/.github/scripts/bootstrap_sc_compose.py new file mode 100644 index 00000000..487a6457 --- /dev/null +++ b/.github/scripts/bootstrap_sc_compose.py @@ -0,0 +1,149 @@ +#!/usr/bin/env python3 +"""Provision the pinned sc-compose Python bindings used by publish-kit scripts.""" + +from __future__ import annotations + +import argparse +import subprocess +import sys +from pathlib import Path + + +# The one exact renderer version used by every Python invocation in this +# package. The published wheel provides bindings only; publisher agents use +# their consumer's CLI and do not import these bindings directly. +SC_COMPOSE_VERSION = "1.5.0" + + +def python_path(venv: Path) -> Path: + """Return the platform-specific interpreter path in a virtual environment.""" + directory = "Scripts" if sys.platform == "win32" else "bin" + executable = "python.exe" if sys.platform == "win32" else "python" + return venv / directory / executable + + +def installed_version(python: Path) -> str | None: + """Return installed distribution metadata before importing the binding module.""" + result = subprocess.run( + [ + str(python), + "-c", + "from importlib.metadata import version; print(version('sc-compose'))", + ], + check=False, + capture_output=True, + text=True, + ) + return result.stdout.strip() if result.returncode == 0 else None + + +def require_pinned_version(installed: str) -> None: + """Fail unless the managed wheel exactly matches the package contract.""" + if installed != SC_COMPOSE_VERSION: + raise SystemExit( + "managed environment has incompatible sc-compose wheel: " + f"found {installed!r}; required exactly {SC_COMPOSE_VERSION}." + ) + + +def install_pinned_wheel(python: Path) -> None: + """Install the one wheel version the package supports.""" + subprocess.run( + [ + str(python), + "-m", + "pip", + "install", + "--disable-pip-version-check", + f"sc-compose=={SC_COMPOSE_VERSION}", + ], + check=True, + stdout=sys.stderr, + ) + + +def provision_pinned_wheel(python: Path) -> None: + """Install or replace a managed wheel until it exactly matches the pin.""" + existing = installed_version(python) + if existing != SC_COMPOSE_VERSION: + install_pinned_wheel(python) + existing = installed_version(python) + if existing is None: + raise SystemExit( + "managed environment has incompatible sc-compose wheel: " + "installation completed but its version could not be determined" + ) + require_pinned_version(existing) + + +def renderer_cli_path(venv: Path) -> Path: + """Return the platform-specific renderer CLI path in a virtual environment.""" + directory = "Scripts" if sys.platform == "win32" else "bin" + return venv / directory / "renderer" + + +def write_cli_wrapper(venv: Path, python: Path) -> Path: + """Write a `sc-compose render` compatible CLI that uses the pinned wheel.""" + wrapper = renderer_cli_path(venv) + wrapper.parent.mkdir(parents=True, exist_ok=True) + wrapper.write_text( + f"""#!{python} +import argparse +import json +from pathlib import Path + +import sc_compose + + +def main() -> int: + parser = argparse.ArgumentParser(description="Pinned sc-compose renderer CLI") + parser.add_argument("command", choices=["render"]) + parser.add_argument("--mode", required=True, choices=["file"]) + parser.add_argument("--root", required=True) + parser.add_argument("--file", required=True) + parser.add_argument("--var-file", required=True) + parser.add_argument("--output", required=True) + args = parser.parse_args() + variables = json.loads(Path(args.var_file).read_text(encoding="utf-8")) + request = sc_compose.ComposeRequest( + root=args.root, + mode=sc_compose.ComposeMode.file(args.file), + vars_input=variables, + policy=sc_compose.ComposePolicy(strict_undeclared_variables=False), + ) + Path(args.output).write_text(sc_compose.compose_file(request).rendered_text, encoding="utf-8") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) +""", + encoding="utf-8", + ) + wrapper.chmod(0o755) + return wrapper + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--venv", required=True, type=Path, help="managed virtual environment") + parser.add_argument( + "--write-cli", + action="store_true", + help="also write a sc-compose render compatible CLI into the venv", + ) + args = parser.parse_args() + venv = args.venv.resolve() + python = python_path(venv) + if not python.is_file(): + subprocess.run([sys.executable, "-m", "venv", str(venv)], check=True) + + provision_pinned_wheel(python) + if args.write_cli: + write_cli_wrapper(venv, python) + print(python) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.github/scripts/release_artifacts.py b/.github/scripts/release_artifacts.py new file mode 100644 index 00000000..6af552c3 --- /dev/null +++ b/.github/scripts/release_artifacts.py @@ -0,0 +1,989 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import json +import re +import shutil +import tarfile +import tomllib +import zipfile +from email import message_from_bytes +from pathlib import Path + +from release_manifest import ( + _assert_python_package_version, + _assert_workspace_inherited_version, + _channel_config, + _channel_contract, + _channel_names, + _channel_preflight_result, + _homebrew_formulas_for_tag, + _public_registry_checks, + _validate_homebrew_formulas, + _python_distribution_entries, + _python_distribution_expectations, + _python_project_name, + _python_project_version, + _release_targets_by_name, + _renderer_archive_path, + _require_keys, + _require_project, + load_channel_contracts, + load_manifest, + manifest_python_upload_tool, + manifest_rust_toolchain, + manifest_workspace_toml, + package_name, + workspace_members, + workspace_version, + validate_publish_order, +) +from release_registry import cmd_check_version_unpublished, cmd_registry_status + + +def _channel_dispatch_config(manifest: dict, channel_name: str) -> tuple[str, dict[str, str]]: + channel = _channel_config(manifest, channel_name) + _require_keys(channel, ("workflow", "dispatch_inputs"), f"[channels.{channel_name}]") + workflow = channel["workflow"] + dispatch_inputs = channel["dispatch_inputs"] + if not isinstance(workflow, str) or not workflow: + raise SystemExit(f"[channels.{channel_name}].workflow must be a non-empty string") + if not isinstance(dispatch_inputs, dict) or not all( + isinstance(key, str) and isinstance(value, str) + for key, value in dispatch_inputs.items() + ): + raise SystemExit( + f"[channels.{channel_name}].dispatch_inputs must be a string-to-string table" + ) + if "tag" in dispatch_inputs: + raise SystemExit(f"[channels.{channel_name}].dispatch_inputs must not override tag") + return workflow, dispatch_inputs + +def _channel_credential_rehearsal( + manifest: dict, channel_name: str +) -> tuple[str, dict[str, str]] | None: + """Return a safe channel rehearsal for credentials not safely probed in preflight.""" + channel = _channel_config(manifest, channel_name) + rehearsal_inputs = channel.get("credential_rehearsal_inputs") + if rehearsal_inputs is None: + return None + if not isinstance(rehearsal_inputs, dict) or not all( + isinstance(key, str) and isinstance(value, str) + for key, value in rehearsal_inputs.items() + ): + raise SystemExit( + f"[channels.{channel_name}].credential_rehearsal_inputs " + "must be a string-to-string table" + ) + if "tag" in rehearsal_inputs: + raise SystemExit( + f"[channels.{channel_name}].credential_rehearsal_inputs must not override tag" + ) + workflow, _ = _channel_dispatch_config(manifest, channel_name) + return workflow, rehearsal_inputs + +def _post_release_channel_preflight(manifest: dict, channel_name: str) -> dict[str, object]: + """Return the non-secret readiness contract a channel worker must consume.""" + contract = _channel_contract(manifest, channel_name) + if contract["stage"] != "post_release": + raise SystemExit(f"channel contract {channel_name} is not a post-release channel") + + rehearsal = _channel_credential_rehearsal(manifest, channel_name) + rehearsal_plan = None + if rehearsal is not None: + workflow, inputs = rehearsal + rehearsal_plan = {"workflow": workflow, "inputs": inputs} + + return { + "agent": contract["agent"], + "repository_secrets": contract.get("repository_secrets", []), + "environment_secrets": contract.get("environment_secrets", []), + "liveness_checks": contract.get("liveness_checks", []), + "public_registry_checks": contract.get("public_registry_checks", False), + "credential_rehearsal": rehearsal_plan, + } + +def _root_channel_preflight(manifest: dict) -> list[dict[str, object]]: + """Return non-secret requirements for root-workflow publish channels.""" + channels: list[dict[str, object]] = [] + if manifest["crates"]: + contract = _channel_contract(manifest, "crates_io") + channels.append( + { + "name": "crates_io", + "agent": contract["agent"], + "repository_secrets": contract.get("repository_secrets", []), + "environment_secrets": contract.get("environment_secrets", []), + "liveness_checks": contract.get("liveness_checks", []), + "public_registry_checks": contract.get("public_registry_checks", False), + "credential_rehearsal": None, + } + ) + contract = _channel_contract(manifest, "github_release") + channels.append( + { + "name": "github_release", + "agent": contract["agent"], + "repository_secrets": contract.get("repository_secrets", []), + "environment_secrets": contract.get("environment_secrets", []), + "liveness_checks": contract.get("liveness_checks", []), + "github_actions_permissions": contract.get("github_actions_permissions", []), + "public_registry_checks": contract.get("public_registry_checks", False), + "credential_rehearsal": None, + } + ) + return channels +def cmd_channel_preflight_results(args: argparse.Namespace) -> int: + """Emit one non-secret result for every root and post-release channel.""" + try: + outcomes = json.loads(args.outcomes) + except json.JSONDecodeError as error: + raise SystemExit(f"invalid preflight outcomes JSON: {error.msg}") from error + if not isinstance(outcomes, dict) or not all( + isinstance(name, str) + and ( + isinstance(outcome, str) + or ( + isinstance(outcome, dict) + and all( + isinstance(channel, str) and isinstance(status, str) + for channel, status in outcome.items() + ) + ) + ) + for name, outcome in outcomes.items() + ): + raise SystemExit( + "preflight outcomes must map each check to a string or channel-status object" + ) + + manifest = load_manifest(Path(args.manifest), with_channel_contracts=True) + contracts = [ + *_root_channel_preflight(manifest), + *[ + {"name": channel_name, **_post_release_channel_preflight(manifest, channel_name)} + for channel_name in _channel_names(manifest) + ], + ] + tag = args.tag or None + results = [ + _channel_preflight_result(channel, outcomes, tag) for channel in contracts + ] + print(json.dumps({"tag": tag, "channels": results}, separators=(",", ":"))) + return 0 + + +def cmd_public_registry_check_plan(args: argparse.Namespace) -> int: + """Emit non-secret public name/version checks for Release Preflight.""" + manifest = load_manifest(Path(args.manifest), with_channel_contracts=True) + checks: list[dict[str, str | None]] = [] + + for crate in manifest["crates"]: + checks.extend( + _public_registry_checks( + manifest["channel_contracts"], "crates_io", crate["package"], args.version + ) + ) + + for distribution in _python_distribution_entries(manifest): + checks.extend( + _public_registry_checks( + manifest["channel_contracts"], "pypi", distribution["name"], args.version + ) + ) + print(json.dumps({"checks": checks}, separators=(",", ":"))) + return 0 + + +def cmd_public_registry_inquiry_plan(args: argparse.Namespace) -> int: + """Emit a direct, read-only candidate name/version lookup plan from contracts.""" + contracts = load_channel_contracts(Path(args.contracts)) + checks = _public_registry_checks(contracts, args.channel, args.name, args.version) + print(json.dumps({"checks": checks}, separators=(",", ":"))) + return 0 + + +def _channel_renderer_target(manifest: dict, channel_name: str) -> dict | None: + """Return the published Linux renderer asset required by a channel workflow.""" + if channel_name not in ("homebrew", "scoop"): + return None + + channel = _channel_config(manifest, channel_name) + _require_keys(channel, ("renderer_target",), f"[channels.{channel_name}]") + target_name = channel["renderer_target"] + targets = _release_targets_by_name(manifest) + try: + target = targets[target_name] + except KeyError as error: + raise SystemExit( + f"[channels.{channel_name}].renderer_target references unknown release target: {target_name}" + ) from error + if target["os"] != "ubuntu-latest" or target["archive"] != "tar.gz": + raise SystemExit( + f"[channels.{channel_name}].renderer_target must name an ubuntu-latest tar.gz release target" + ) + return target + + +def _release_asset_pattern(project: dict, target: dict) -> str: + return ( + rf"^{re.escape(project['archive_prefix'])}_.*_" + rf"{re.escape(target['target'])}\.{re.escape(target['archive'])}$" + ) + + +def _release_binaries(manifest: dict) -> list[dict]: + binaries = manifest["release_binaries"] + if not binaries: + raise SystemExit("manifest must define [[release_binaries]]") + for index, binary in enumerate(binaries, start=1): + _require_keys(binary, ("name",), f"[[release_binaries]] #{index}") + for bundle in binary.get("bundled_paths", []): + _require_keys(bundle, ("source", "destination"), "bundled_paths entry") + return binaries + + +def _validate_homebrew_bundle_destinations(binaries: list[dict]) -> None: + """Require explicit, safe Homebrew Pathname components for bundled assets.""" + for binary in binaries: + for bundle in binary.get("bundled_paths", []): + components = bundle.get("homebrew_destination_components") + if not isinstance(components, list) or not components or not all( + isinstance(component, str) and component for component in components + ): + raise SystemExit( + "bundled_paths entry must define non-empty " + "homebrew_destination_components when Homebrew is configured" + ) + if re.fullmatch(r"[a-z_][a-z0-9_]*", components[0]) is None: + raise SystemExit( + "bundled_paths homebrew_destination_components[0] must be a " + "lowercase Homebrew Pathname helper" + ) + + +def _validate_scoop_channel(manifest: dict) -> None: + """Require the generic Scoop workflow inputs to be manifest-declared.""" + channel = _channel_config(manifest, "scoop") + _require_keys( + channel, + ("bucket_repository", "manifest_path", "manifest_template", "binary"), + "[channels.scoop]", + ) + for key in ("bucket_repository", "manifest_path", "manifest_template", "binary"): + if not isinstance(channel[key], str) or not channel[key]: + raise SystemExit(f"[channels.scoop].{key} must be a non-empty string") + + +def _channel_asset_patterns(manifest: dict, channel_name: str) -> list[str]: + project = _require_project(manifest) + targets = _release_targets_by_name(manifest) + channel = _channel_config(manifest, channel_name) + if channel_name == "homebrew": + assets = channel.get("assets", []) + if not assets: + raise SystemExit("[channels.homebrew] must define [[channels.homebrew.assets]]") + target_names = [] + for asset in assets: + _require_keys(asset, ("key", "target"), "[[channels.homebrew.assets]]") + target_names.append(asset["target"]) + elif channel_name in ("winget", "scoop"): + _require_keys(channel, ("installer_target",), f"[channels.{channel_name}]") + target_names = [channel["installer_target"]] + else: + return [] + + renderer_target = _channel_renderer_target(manifest, channel_name) + if renderer_target is not None: + target_names.append(renderer_target["target"]) + + missing = [name for name in target_names if name not in targets] + if missing: + raise SystemExit( + f"[channels.{channel_name}] references unknown release target(s): {', '.join(missing)}" + ) + return [ + _release_asset_pattern(project, targets[name]) + for name in dict.fromkeys(target_names) + ] + + +def cmd_validate_manifest(args: argparse.Namespace) -> int: + manifest = load_manifest(Path(args.manifest), with_channel_contracts=True) + _require_project(manifest) + _release_targets_by_name(manifest) + binaries = _release_binaries(manifest) + channel_names = _channel_names(manifest) + for channel_name in channel_names: + _channel_dispatch_config(manifest, channel_name) + _channel_credential_rehearsal(manifest, channel_name) + _channel_asset_patterns(manifest, channel_name) + if channel_name in ("homebrew", "scoop"): + _renderer_archive_path(manifest) + # Values only read at publish time must still fail validation early. + required_channel_strings = { + "pypi": ("test_repository", "production_repository"), + "winget": ("identifier",), + }.get(channel_name, ()) + channel = _channel_config(manifest, channel_name) + for key in required_channel_strings: + if not isinstance(channel.get(key), str) or not channel[key]: + raise SystemExit(f"[channels.{channel_name}].{key} must be a non-empty string") + if "homebrew" in channel_names: + _validate_homebrew_bundle_destinations(binaries) + _validate_homebrew_formulas( + _channel_config(manifest, "homebrew"), + {binary["name"] for binary in binaries}, + ) + if "scoop" in channel_names: + _validate_scoop_channel(manifest) + seen = set() + if manifest["crates"]: + members = workspace_members(Path(args.workspace_toml)) + missing = [] + for crate in manifest["crates"]: + if crate["cargo_toml"].removesuffix("/Cargo.toml") not in members: + missing.append(crate["cargo_toml"]) + if missing: + raise SystemExit(f"manifest references non-member crates: {', '.join(missing)}") + for crate in manifest["crates"]: + artifact = crate["artifact"] + if artifact in seen: + raise SystemExit(f"duplicate artifact: {artifact}") + seen.add(artifact) + actual = package_name(Path(crate["cargo_toml"])) + if actual != crate["package"]: + raise SystemExit(f"{crate['cargo_toml']}: package mismatch: manifest={crate['package']} actual={actual}") + python_artifacts = set() + python_packages_by_name: dict[str, dict] = {} + python_distributions_by_name = {entry["name"]: entry for entry in manifest["python_distributions"]} + for index, package in enumerate(manifest["python_packages"], start=1): + _require_keys(package, ("artifact", "package", "manifest", "module", "publish"), f"[[python_packages]] #{index}") + artifact = package["artifact"] + if artifact in seen or artifact in python_artifacts: + raise SystemExit(f"duplicate artifact: {artifact}") + python_artifacts.add(artifact) + manifest_path = Path(package["manifest"]) + if not manifest_path.is_file(): + raise SystemExit(f"{manifest_path}: missing Python package manifest") + distribution = python_distributions_by_name.get(package["package"], {}) + cargo_manifest = distribution.get("cargo_manifest") + python_package_version = _python_project_version(manifest_path) + if not python_package_version and cargo_manifest: + cargo_data = tomllib.loads((Path(args.workspace_toml).parent / cargo_manifest).read_text(encoding="utf-8")) + python_package_version = cargo_data.get("package", {}).get("version") + if isinstance(python_package_version, dict) and python_package_version.get("workspace") is True: + python_package_version = workspace_version(Path(args.workspace_toml)) + if not python_package_version: + raise SystemExit(f"{manifest_path}: missing [project].version") + actual_package_name = _python_project_name(manifest_path) + if actual_package_name != package["package"]: + raise SystemExit( + f"{manifest_path}: python package mismatch: manifest={package['package']} actual={actual_package_name}" + ) + python_packages_by_name[package["package"]] = package + for index, distribution in enumerate(manifest["python_distributions"], start=1): + _require_keys(distribution, ("name", "source", "sdist", "wheels"), f"[[python_distributions]] #{index}") + if distribution["name"] not in python_packages_by_name: + raise SystemExit( + f"[[python_distributions]] #{index}: no matching [[python_packages]] entry for {distribution['name']}" + ) + source = Path(distribution["source"]) + if not source.is_dir(): + raise SystemExit(f"[[python_distributions]] #{index}: source directory does not exist: {source}") + if not isinstance(distribution["sdist"], bool): + raise SystemExit(f"[[python_distributions]] #{index}: sdist must be a boolean") + wheels = distribution["wheels"] + if not isinstance(wheels, list) or not all(isinstance(entry, str) for entry in wheels): + raise SystemExit(f"[[python_distributions]] #{index}: wheels must be a list of strings") + cargo_manifest = distribution.get("cargo_manifest") + if cargo_manifest and not (Path(cargo_manifest)).is_file(): + raise SystemExit( + f"[[python_distributions]] #{index}: missing Maturin Cargo manifest: {cargo_manifest}" + ) + package = python_packages_by_name[distribution["name"]] + module_root = Path(distribution.get("module_path", source / "python" / package["module"])) + if not module_root.is_dir(): + raise SystemExit( + f"[[python_distributions]] #{index}: Python module path does not exist: {module_root}" + ) + # Resolves each distribution's build system; a missing or unsupported + # build_system is a manifest validation failure. + _python_distribution_entries(manifest) + print("manifest validation passed") + return 0 + + +def cmd_list_publish_plan(args: argparse.Namespace) -> int: + manifest = load_manifest(Path(args.manifest)) + for crate in manifest["crates"]: + print(f"{crate['package']}|{crate['wait_after_publish_seconds']}") + return 0 + + +def _python_matrix_entry(distribution: dict) -> dict[str, str]: + return { + "artifact": distribution["artifact"], + "name": distribution["name"], + "source": distribution["source"], + "pyproject": distribution["pyproject"], + "cargo_manifest": distribution["cargo_manifest"] or "", + "build_system": distribution["build_system"], + } + + +def cmd_python_wheel_matrix(args: argparse.Namespace) -> int: + manifest = load_manifest(Path(args.manifest)) + # An empty matrix is valid: Rust-only consumers build no Python wheels, + # and release.yml skips the wheel jobs when the matrix is empty. + include = [ + {**_python_matrix_entry(distribution), "os": os_name} + for distribution in _python_distribution_entries(manifest) + for os_name in distribution["wheels"] + ] + print(json.dumps({"include": include}, separators=(",", ":"))) + return 0 + + +def cmd_python_sdist_matrix(args: argparse.Namespace) -> int: + manifest = load_manifest(Path(args.manifest)) + include = [ + _python_matrix_entry(distribution) + for distribution in _python_distribution_entries(manifest) + if distribution["sdist"] + ] + print(json.dumps({"include": include}, separators=(",", ":"))) + return 0 + + +def cmd_build_plan(args: argparse.Namespace) -> int: + """Emit the manifest-derived flags that gate optional build legs.""" + manifest = load_manifest(Path(args.manifest)) + entries = _python_distribution_entries(manifest) + plan = { + "has_crates": bool(manifest["crates"]), + "has_python_wheels": any(entry["wheels"] for entry in entries), + "has_python_sdists": any(entry["sdist"] for entry in entries), + "python_upload_tool": manifest_python_upload_tool(manifest), + "workspace_toml": manifest_workspace_toml(manifest), + "rust_toolchain": manifest_rust_toolchain(manifest), + } + print(json.dumps(plan, separators=(",", ":"))) + return 0 + + +def cmd_release_asset_patterns(args: argparse.Namespace) -> int: + """Print one required-asset regex per manifest release target.""" + manifest = load_manifest(Path(args.manifest)) + project = _require_project(manifest) + for target in _release_targets_by_name(manifest).values(): + print(_release_asset_pattern(project, target)) + return 0 + + +def cmd_release_target_matrix(args: argparse.Namespace) -> int: + manifest = load_manifest(Path(args.manifest)) + print(json.dumps({"include": list(_release_targets_by_name(manifest).values())}, separators=(",", ":"))) + return 0 + + +def cmd_release_package_config(args: argparse.Namespace) -> int: + manifest = load_manifest(Path(args.manifest)) + targets = _release_targets_by_name(manifest) + try: + target = targets[args.target] + except KeyError as error: + raise SystemExit(f"unknown release target: {args.target}") from error + binaries = _release_binaries(manifest) + print( + json.dumps( + {"project": _require_project(manifest), "target": target, "binaries": binaries}, + separators=(",", ":"), + ) + ) + return 0 + + +def cmd_channel_config(args: argparse.Namespace) -> int: + manifest = load_manifest(Path(args.manifest)) + project = _require_project(manifest) + channel = dict(_channel_config(manifest, args.channel)) + if args.channel == "homebrew" and args.tag is not None: + channel["formulas"] = _homebrew_formulas_for_tag( + channel, + args.tag, + {binary["name"] for binary in _release_binaries(manifest)}, + ) + result = { + "project": project, + "channel": channel, + "asset_patterns": _channel_asset_patterns(manifest, args.channel), + "release_binaries": manifest["release_binaries"], + "release_targets": _release_targets_by_name(manifest), + } + print(json.dumps(result, separators=(",", ":"))) + return 0 + + +def cmd_channel_dispatch_plan(args: argparse.Namespace) -> int: + manifest = load_manifest(Path(args.manifest), with_channel_contracts=True) + channels = [] + for channel_name in _channel_names(manifest): + workflow, dispatch_inputs = _channel_dispatch_config(manifest, channel_name) + preflight = _post_release_channel_preflight(manifest, channel_name) + rehearsal = preflight["credential_rehearsal"] + rehearsal_plan = None + if rehearsal is not None: + rehearsal_plan = { + "workflow": rehearsal["workflow"], + "inputs": {"tag": args.tag, **rehearsal["inputs"]}, + } + channels.append( + { + "name": channel_name, + "agent": preflight["agent"], + "workflow": workflow, + "inputs": {"tag": args.tag, **dispatch_inputs}, + "credential_rehearsal": rehearsal_plan, + "preflight": preflight, + } + ) + print(json.dumps({"channels": channels}, separators=(",", ":"))) + return 0 + + +def cmd_preflight_secret_plan(args: argparse.Namespace) -> int: + manifest = load_manifest(Path(args.manifest), with_channel_contracts=True) + channel_names = _channel_names(manifest) + repository_secrets: list[str] = [] + repository_secret_channels: list[dict[str, object]] = [] + liveness_checks: list[dict[str, str]] = [] + liveness_channel_checks: list[dict[str, str]] = [] + environment_secrets: list[dict[str, str]] = [] + root_channels = _root_channel_preflight(manifest) + + for channel in root_channels: + repository_secrets.extend(channel["repository_secrets"]) + if channel["repository_secrets"]: + repository_secret_channels.append( + {"name": channel["name"], "secrets": channel["repository_secrets"]} + ) + environment_secrets.extend(channel["environment_secrets"]) + liveness_checks.extend(channel["liveness_checks"]) + liveness_channel_checks.extend( + {"channel": channel["name"], **check} + for check in channel["liveness_checks"] + ) + post_release_channels = [] + for channel_name in channel_names: + channel_preflight = _post_release_channel_preflight(manifest, channel_name) + repository_secrets.extend(channel_preflight["repository_secrets"]) + if channel_preflight["repository_secrets"]: + repository_secret_channels.append( + {"name": channel_name, "secrets": channel_preflight["repository_secrets"]} + ) + environment_secrets.extend(channel_preflight["environment_secrets"]) + liveness_checks.extend(channel_preflight["liveness_checks"]) + liveness_channel_checks.extend( + {"channel": channel_name, **check} + for check in channel_preflight["liveness_checks"] + ) + post_release_channels.append({"name": channel_name, **channel_preflight}) + + # Workflow-consumed GitHub environments are contract-declared so the + # preflight can verify they exist before any release dispatch. + github_environments: list[str] = [] + contracts = manifest["channel_contracts"] + for contract_name in ("crates_io", "github_release", *channel_names): + for environment in contracts.get(contract_name, {}).get("environments", []): + if environment not in github_environments: + github_environments.append(environment) + for secret in environment_secrets: + if secret["environment"] not in github_environments: + github_environments.append(secret["environment"]) + + print( + json.dumps( + { + "repository_secrets": repository_secrets, + "repository_secret_channels": repository_secret_channels, + "environment_secrets": environment_secrets, + "github_environments": github_environments, + "liveness_checks": liveness_checks, + "liveness_channel_checks": liveness_channel_checks, + "root_channels": root_channels, + "post_release_channels": post_release_channels, + }, + separators=(",", ":"), + ) + ) + return 0 + + +def _python_distribution_name_from_wheel(path: Path, expected: set[str]) -> str: + with zipfile.ZipFile(path) as archive: + metadata = [name for name in archive.namelist() if name.endswith(".dist-info/METADATA")] + if len(metadata) != 1: + raise SystemExit(f"{path}: expected exactly one wheel METADATA file") + name = message_from_bytes(archive.read(metadata[0])).get("Name") + if name not in expected: + raise SystemExit(f"{path}: unexpected Python distribution {name!r}") + return name + + +def _python_distribution_name_from_sdist(path: Path, expected: set[str]) -> str | None: + with tarfile.open(path, "r:gz") as archive: + metadata = [member for member in archive.getmembers() if member.name.endswith("/PKG-INFO")] + if not metadata: + return None + if len(metadata) != 1: + raise SystemExit(f"{path}: expected exactly one sdist PKG-INFO file") + extracted = archive.extractfile(metadata[0]) + if extracted is None: + raise SystemExit(f"{path}: unable to read sdist PKG-INFO") + name = message_from_bytes(extracted.read()).get("Name") + if name not in expected: + raise SystemExit(f"{path}: unexpected Python distribution {name!r}") + return name + + +def cmd_verify_python_release_assets(args: argparse.Namespace) -> int: + manifest = load_manifest(Path(args.manifest)) + asset_dir = Path(args.asset_dir) + if not asset_dir.is_dir(): + raise SystemExit(f"Python asset directory does not exist: {asset_dir}") + expected = _python_distribution_expectations(manifest) + found = {name: {"wheel": 0, "sdist": 0} for name in expected} + destination = Path(args.copy_to) if args.copy_to else None + if destination: + destination.mkdir(parents=True, exist_ok=True) + + for asset in sorted(asset_dir.iterdir()): + if not asset.is_file(): + continue + if asset.suffix == ".whl": + name = _python_distribution_name_from_wheel(asset, set(expected)) + found[name]["wheel"] += 1 + elif asset.name.endswith(".tar.gz"): + name = _python_distribution_name_from_sdist(asset, set(expected)) + if name is None: + continue + found[name]["sdist"] += 1 + else: + continue + if destination: + shutil.copy2(asset, destination / asset.name) + + if found != expected: + raise SystemExit( + "published GitHub Release Python assets mismatch: " + f"expected {expected}, found {found}" + ) + print(f"verified Python release assets: {expected}") + return 0 + + +def cmd_verify_version(args: argparse.Namespace) -> int: + version = workspace_version(Path(args.workspace_toml)) + if version != args.version: + raise SystemExit(f"workspace version mismatch: expected {args.version}, got {version}") + manifest = load_manifest(Path(args.manifest)) + for crate in manifest["crates"]: + data = tomllib.loads(Path(crate["cargo_toml"]).read_text(encoding='utf-8')) + pkg_version = data["package"]["version"] + if isinstance(pkg_version, str): + actual = pkg_version + elif isinstance(pkg_version, dict) and pkg_version.get("workspace") is True: + actual = version + else: + raise SystemExit(f"{crate['package']}: unsupported version shape: {pkg_version!r}") + if actual != version: + raise SystemExit(f"{crate['package']}: version mismatch: expected {version}, got {actual}") + print("version verification passed") + return 0 + + +def cmd_verify_version_lockstep(args: argparse.Namespace) -> int: + workspace_toml = Path(args.workspace_toml) + version = workspace_version(workspace_toml) + manifest = load_manifest(Path(args.manifest)) + checked_cargo_manifests: set[str] = set() + for crate in manifest["crates"]: + cargo_toml = crate["cargo_toml"] + _assert_workspace_inherited_version( + workspace_toml, + cargo_toml, + allow_literal_base=not crate.get("publish", True), + ) + checked_cargo_manifests.add(cargo_toml) + for distribution in _python_distribution_entries(manifest): + cargo_toml = distribution["cargo_manifest"] + if cargo_toml and cargo_toml not in checked_cargo_manifests: + _assert_workspace_inherited_version(workspace_toml, cargo_toml, allow_literal_base=True) + checked_cargo_manifests.add(cargo_toml) + for package in manifest["python_packages"]: + distribution = next( + (entry for entry in _python_distribution_entries(manifest) if entry["name"] == package["package"]), + None, + ) + _assert_python_package_version( + workspace_toml, + package["manifest"], + version, + cargo_manifest=distribution["cargo_manifest"] if distribution else None, + ) + print("version lockstep verification passed") + return 0 + + +def cmd_verify_python_version(args: argparse.Namespace) -> int: + version = workspace_version(Path(args.workspace_toml)) + if version != args.version: + raise SystemExit(f"workspace version mismatch: expected {args.version}, got {version}") + actual = _python_project_version(Path(args.pyproject)) + if actual != version: + raise SystemExit(f"python package version mismatch: expected {version}, got {actual}") + print("python version verification passed") + return 0 + + +def cmd_sync_python_version(args: argparse.Namespace) -> int: + version = workspace_version(Path(args.workspace_toml)) + pyproject = Path(args.pyproject) + lines = pyproject.read_text(encoding="utf-8").splitlines() + output: list[str] = [] + in_project = False + updated = False + + for line in lines: + stripped = line.strip() + if stripped.startswith("[") and stripped.endswith("]"): + in_project = stripped == "[project]" + if in_project and re.match(r'^\s*version\s*=\s*"[^"]+"\s*$', line): + output.append(re.sub(r'"[^"]+"', f'"{version}"', line, count=1)) + updated = True + continue + output.append(line) + + if not updated: + raise SystemExit(f"{pyproject}: could not find [project].version to rewrite") + + pyproject.write_text("\n".join(output) + "\n", encoding="utf-8") + print(f"synced python package version to {version}") + return 0 + + +def _readme_dependency_crate(manifest: dict) -> str: + project = manifest["project"] + dependency_crate = project.get("readme_dependency_crate") + if not isinstance(dependency_crate, str) or not dependency_crate: + raise SystemExit("[project].readme_dependency_crate must be a non-empty string") + if dependency_crate not in {crate["package"] for crate in manifest["crates"]}: + raise SystemExit( + "[project].readme_dependency_crate must name a package declared in [[crates]]" + ) + return dependency_crate + + +def _readme_version_checks( + version: str, dependency_crate: str +) -> tuple[tuple[str, str, str], ...]: + minor_version = version.rsplit(".", 1)[0] + return ( + ( + f"{dependency_crate} dependency example", + rf'({re.escape(dependency_crate)}\s*=\s*")[^"]+(")', + version, + ), + ("Status table Version row", rf'(\|\s*Version\s*\|\s*)[^\s|]+(\s*\|)', version), + ("Status table Stability row", rf'(\|\s*Stability\s*\|\s*stable\s+)\S+(\s+release line\s*\|)', minor_version), + ) + + +def cmd_verify_readme_version(args: argparse.Namespace) -> int: + version = workspace_version(Path(args.workspace_toml)) + dependency_crate = _readme_dependency_crate(load_manifest(Path(args.manifest))) + readme = Path(args.readme) + text = readme.read_text(encoding="utf-8") + + mismatches = [] + for label, pattern, expected in _readme_version_checks(version, dependency_crate): + match = re.search(pattern, text) + if match is None: + raise SystemExit(f"{readme}: could not locate {label}") + found = text[match.end(1):match.start(2)] + if found != expected: + mismatches.append(f"{label}: expected {expected}, found {found}") + + if mismatches: + raise SystemExit( + f"{readme}: stale version reference(s) (run 'sync-readme-version' to fix):\n" + + "\n".join(mismatches) + ) + print("readme version verification passed") + return 0 + + +def cmd_sync_readme_version(args: argparse.Namespace) -> int: + version = workspace_version(Path(args.workspace_toml)) + dependency_crate = _readme_dependency_crate(load_manifest(Path(args.manifest))) + readme = Path(args.readme) + text = readme.read_text(encoding="utf-8") + + updated = 0 + for label, pattern, expected in _readme_version_checks(version, dependency_crate): + new_text, count = re.subn(pattern, rf'\g<1>{expected}\g<2>', text, count=1) + if count == 0: + raise SystemExit(f"{readme}: could not locate {label}") + text = new_text + updated += count + + readme.write_text(text, encoding="utf-8") + print(f"synced {updated} readme version reference(s) to {version}") + return 0 + + +def cmd_cargo_build_bin_args(args: argparse.Namespace) -> int: + manifest = load_manifest(Path(args.manifest)) + print(" ".join(f"--bin {entry['name']}" for entry in manifest["release_binaries"])) + return 0 + + +def main() -> int: + parser = argparse.ArgumentParser() + sub = parser.add_subparsers(dest="cmd", required=True) + + p = sub.add_parser("validate-manifest") + p.add_argument("--manifest", required=True) + p.add_argument("--workspace-toml", required=True) + p.set_defaults(func=cmd_validate_manifest) + + p = sub.add_parser("validate-publish-order") + p.add_argument("--manifest", required=True) + p.add_argument("--workspace-toml", required=True) + p.set_defaults(func=validate_publish_order) + + p = sub.add_parser("list-publish-plan") + p.add_argument("--manifest", required=True) + p.set_defaults(func=cmd_list_publish_plan) + + p = sub.add_parser("python-wheel-matrix") + p.add_argument("--manifest", required=True) + p.set_defaults(func=cmd_python_wheel_matrix) + + p = sub.add_parser("python-sdist-matrix") + p.add_argument("--manifest", required=True) + p.set_defaults(func=cmd_python_sdist_matrix) + + p = sub.add_parser("build-plan") + p.add_argument("--manifest", required=True) + p.set_defaults(func=cmd_build_plan) + + p = sub.add_parser("release-asset-patterns") + p.add_argument("--manifest", required=True) + p.set_defaults(func=cmd_release_asset_patterns) + + p = sub.add_parser("release-target-matrix") + p.add_argument("--manifest", required=True) + p.set_defaults(func=cmd_release_target_matrix) + + p = sub.add_parser("release-package-config") + p.add_argument("--manifest", required=True) + p.add_argument("--target", required=True) + p.set_defaults(func=cmd_release_package_config) + + p = sub.add_parser("channel-config") + p.add_argument("--manifest", required=True) + p.add_argument("--channel", required=True) + p.add_argument("--tag") + p.set_defaults(func=cmd_channel_config) + + p = sub.add_parser("channel-dispatch-plan") + p.add_argument("--manifest", required=True) + p.add_argument("--tag", required=True) + p.set_defaults(func=cmd_channel_dispatch_plan) + + p = sub.add_parser("preflight-secret-plan") + p.add_argument("--manifest", required=True) + p.set_defaults(func=cmd_preflight_secret_plan) + + p = sub.add_parser("channel-preflight-results") + p.add_argument("--manifest", required=True) + p.add_argument("--outcomes", required=True) + p.add_argument("--tag", required=True) + p.set_defaults(func=cmd_channel_preflight_results) + + p = sub.add_parser("public-registry-check-plan") + p.add_argument("--manifest", required=True) + p.add_argument("--version", required=True) + p.set_defaults(func=cmd_public_registry_check_plan) + + p = sub.add_parser("public-registry-inquiry-plan") + p.add_argument("--contracts", required=True) + p.add_argument("--channel", choices=("crates_io", "pypi"), required=True) + p.add_argument("--name", required=True) + p.add_argument("--version") + p.set_defaults(func=cmd_public_registry_inquiry_plan) + + p = sub.add_parser("registry-status") + p.add_argument("--url", required=True) + p.add_argument("--timeout", type=int, default=20) + p.set_defaults(func=cmd_registry_status) + + p = sub.add_parser("verify-python-release-assets") + p.add_argument("--manifest", required=True) + p.add_argument("--asset-dir", required=True) + p.add_argument("--copy-to") + p.set_defaults(func=cmd_verify_python_release_assets) + + p = sub.add_parser("verify-version") + p.add_argument("--manifest", required=True) + p.add_argument("--workspace-toml", required=True) + p.add_argument("--version", required=True) + p.set_defaults(func=cmd_verify_version) + + p = sub.add_parser("verify-python-version") + p.add_argument("--workspace-toml", required=True) + p.add_argument("--pyproject", required=True) + p.add_argument("--version", required=True) + p.set_defaults(func=cmd_verify_python_version) + + p = sub.add_parser("verify-version-lockstep") + p.add_argument("--manifest", required=True) + p.add_argument("--workspace-toml", required=True) + p.set_defaults(func=cmd_verify_version_lockstep) + + p = sub.add_parser("sync-python-version") + p.add_argument("--workspace-toml", required=True) + p.add_argument("--pyproject", required=True) + p.set_defaults(func=cmd_sync_python_version) + + p = sub.add_parser("verify-readme-version") + p.add_argument("--manifest", required=True) + p.add_argument("--workspace-toml", required=True) + p.add_argument("--readme", required=True) + p.set_defaults(func=cmd_verify_readme_version) + + p = sub.add_parser("sync-readme-version") + p.add_argument("--manifest", required=True) + p.add_argument("--workspace-toml", required=True) + p.add_argument("--readme", required=True) + p.set_defaults(func=cmd_sync_readme_version) + + p = sub.add_parser("cargo-build-bin-args") + p.add_argument("--manifest", required=True) + p.set_defaults(func=cmd_cargo_build_bin_args) + + p = sub.add_parser("check-version-unpublished") + p.add_argument("--manifest", required=True) + p.add_argument("--version", required=True) + p.add_argument("--already-published-channels", default="") + p.set_defaults(func=cmd_check_version_unpublished) + + args = parser.parse_args() + return args.func(args) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.github/scripts/release_gate.sh b/.github/scripts/release_gate.sh new file mode 100755 index 00000000..de3ad33d --- /dev/null +++ b/.github/scripts/release_gate.sh @@ -0,0 +1,65 @@ +#!/usr/bin/env bash +set -euo pipefail + +MODE="${1:-final}" +RELEASE_REF="${2:-origin/main}" +RELEASE_CANDIDATE_TAG="${3:-}" +VERSION="${4:-${RELEASE_VERSION:-}}" +MANIFEST="${5:-release/publish-artifacts.toml}" +WORKSPACE_TOML="${6:-Cargo.toml}" +ALREADY_PUBLISHED_CHANNELS="${7:-}" + +fail() { + echo "release-gate: FAIL - $*" >&2 + exit 1 +} + +info() { + echo "release-gate: $*" +} + +case "$MODE" in + readiness|final) ;; + *) fail "mode must be readiness or final (got: $MODE)" ;; +esac + +[[ -n "$RELEASE_CANDIDATE_TAG" ]] || fail "release-candidate tag is required (arg 3)" +[[ "$RELEASE_CANDIDATE_TAG" =~ ^release-candidate-v[0-9]+\.[0-9]+\.[0-9]+$ ]] \ + || fail "invalid release-candidate tag: $RELEASE_CANDIDATE_TAG" +[[ -n "$VERSION" ]] || fail "release version is required (arg 4 or RELEASE_VERSION)" +[[ "$RELEASE_CANDIDATE_TAG" == "release-candidate-v${VERSION}" ]] \ + || fail "release-candidate tag $RELEASE_CANDIDATE_TAG does not match version $VERSION" + +info "fetching refs and tags" +git fetch origin --prune --tags >/dev/null 2>&1 || fail "git fetch failed" + +git rev-parse --verify "$RELEASE_REF" >/dev/null 2>&1 || fail "missing release ref: $RELEASE_REF" +git rev-parse --verify "refs/tags/$RELEASE_CANDIDATE_TAG" >/dev/null 2>&1 \ + || fail "missing release-candidate tag: $RELEASE_CANDIDATE_TAG" + +release_sha="$(git rev-parse "$RELEASE_REF")" +candidate_sha="$(git rev-parse "${RELEASE_CANDIDATE_TAG}^{commit}")" +info "mode=$MODE release_ref=$RELEASE_REF release=$release_sha candidate_tag=$RELEASE_CANDIDATE_TAG candidate=$candidate_sha version=$VERSION" + +if ! git merge-base --is-ancestor "$RELEASE_CANDIDATE_TAG" "$RELEASE_REF"; then + fail "$RELEASE_CANDIDATE_TAG ($candidate_sha) is not an ancestor of $RELEASE_REF ($release_sha)" +fi + +if [[ "$MODE" == "final" && "$RELEASE_REF" != "origin/main" ]]; then + fail "final mode must validate origin/main (got: $RELEASE_REF)" +fi + +python3 .github/scripts/release_artifacts.py check-version-unpublished \ + --manifest "$MANIFEST" \ + --version "$VERSION" \ + --already-published-channels "$ALREADY_PUBLISHED_CHANNELS" >/dev/null + +python3 .github/scripts/release_artifacts.py verify-version-lockstep \ + --manifest "$MANIFEST" \ + --workspace-toml "$WORKSPACE_TOML" >/dev/null + +if [[ "$MODE" == "final" && -n "${GITHUB_OUTPUT:-}" ]]; then + printf 'release_sha=%s\n' "$release_sha" >> "$GITHUB_OUTPUT" +fi + +info "PASS - release gate checks satisfied" diff --git a/.github/scripts/release_manifest.py b/.github/scripts/release_manifest.py new file mode 100644 index 00000000..6c0e849c --- /dev/null +++ b/.github/scripts/release_manifest.py @@ -0,0 +1,636 @@ +"""Manifest and channel-contract parsing for the vendorable publish kit.""" +from __future__ import annotations + +import re +import tomllib +from pathlib import Path, PurePosixPath + + +CHANNEL_CONTRACTS_FILE = "publish-channel-contracts.toml" +ROOT_CHANNELS = frozenset({"crates_io", "github_release"}) +SUPPORTED_SCHEMA_VERSION = 1 +# Single source for the release Rust toolchain when the manifest does not +# declare [project].rust_toolchain; workflows read it via `build-plan`. +DEFAULT_RUST_TOOLCHAIN = "1.94.1" + + +def _require_keys(entry: dict, required: tuple[str, ...], label: str) -> None: + missing = [key for key in required if key not in entry] + if missing: + joined = ", ".join(missing) + raise SystemExit(f"{label} missing required keys: {joined}") + + +def load_channel_contracts(path: Path) -> dict[str, dict]: + """Load the vendorable, non-secret protocol for every supported channel.""" + data = tomllib.loads(path.read_text(encoding="utf-8")) + channels = data.get("channels") + if not isinstance(channels, dict): + raise SystemExit(f"{path}: [channels] must be a table") + for name, contract in channels.items(): + if not isinstance(contract, dict): + raise SystemExit(f"{path}: [channels.{name}] must be a table") + _require_keys(contract, ("stage", "agent"), f"{path}: [channels.{name}]") + if contract["stage"] not in {"root", "post_release"}: + raise SystemExit(f"{path}: [channels.{name}].stage must be root or post_release") + if not isinstance(contract["agent"], str) or not contract["agent"]: + raise SystemExit(f"{path}: [channels.{name}].agent must be a non-empty string") + missing_roots = ROOT_CHANNELS - set(channels) + if missing_roots: + raise SystemExit(f"{path}: missing required root channel(s): {', '.join(sorted(missing_roots))}") + return channels + + +def load_manifest(path: Path, *, with_channel_contracts: bool = False) -> dict: + data = tomllib.loads(path.read_text(encoding="utf-8")) + schema_version = data.get("schema_version", SUPPORTED_SCHEMA_VERSION) + if schema_version != SUPPORTED_SCHEMA_VERSION: + raise SystemExit(f"unsupported manifest schema_version: {schema_version!r}") + # An empty crates list is valid: pure-Python consumers publish no Rust + # crates, and every Cargo step gates on the manifest's crates. + crates = data.get("crates", []) + release_binaries = data.get("release_binaries", []) + python_packages = data.get("python_packages", []) + python_distributions = data.get("python_distributions", []) + crates = sorted(crates, key=lambda item: (item["publish_order"], item["artifact"])) + manifest = { + "project": data.get("project", {}), + "crates": crates, + "release_binaries": release_binaries, + "release_targets": data.get("release_targets", []), + "python_packages": python_packages, + "python_distributions": python_distributions, + "channels": data.get("channels", {}), + } + if with_channel_contracts: + manifest["channel_contracts"] = load_channel_contracts( + path.parent / CHANNEL_CONTRACTS_FILE + ) + return manifest + + +def manifest_workspace_toml(manifest: dict) -> str: + """Return the manifest-declared Cargo workspace manifest path.""" + value = manifest["project"].get("workspace_toml", "Cargo.toml") + if not isinstance(value, str) or not value: + raise SystemExit("[project].workspace_toml must be a non-empty string") + return value + + +def manifest_python_upload_tool(manifest: dict) -> str: + """Return the PyPI uploader implied by the declared build systems. + + maturin's uploader handles wheels and sdists from any build system, so a + manifest with at least one maturin distribution keeps maturin. A purely + setuptools consumer has no Rust toolchain and uploads with twine. Empty + when the manifest declares no Python distributions. + """ + entries = _python_distribution_entries(manifest) + if not entries: + return "" + if any(entry["build_system"] == "maturin" for entry in entries): + return "maturin" + return "twine" + + +def manifest_rust_toolchain(manifest: dict) -> str: + """Return the manifest-declared release Rust toolchain.""" + value = manifest["project"].get("rust_toolchain", DEFAULT_RUST_TOOLCHAIN) + if not isinstance(value, str) or not value: + raise SystemExit("[project].rust_toolchain must be a non-empty string") + return value + + +def workspace_members(workspace_toml: Path) -> set[str]: + data = tomllib.loads(workspace_toml.read_text(encoding="utf-8")) + return set(data.get("workspace", {}).get("members", [])) + + +def package_name(cargo_toml: Path) -> str: + data = tomllib.loads(cargo_toml.read_text(encoding="utf-8")) + return data["package"]["name"] + + +def workspace_dependency_names(crate_toml: Path, workspace_toml: Path) -> set[str]: + """Return dependencies that resolve to another package in this workspace.""" + workspace_root = workspace_toml.parent.resolve() + workspace_data = tomllib.loads(workspace_toml.read_text(encoding="utf-8")) + workspace_deps = workspace_data.get("workspace", {}).get("dependencies", {}) + workspace_packages = { + package_name(workspace_root / member / "Cargo.toml") + for member in workspace_members(workspace_toml) + if (workspace_root / member / "Cargo.toml").is_file() + } + crate_data = tomllib.loads(crate_toml.read_text(encoding="utf-8")) + crate_dir = crate_toml.parent + dependencies: set[str] = set() + + def resolve(name: str, spec: object) -> str | None: + if isinstance(spec, str): + return name if name in workspace_packages else None + if not isinstance(spec, dict): + return None + if spec.get("workspace") is True: + workspace_spec = workspace_deps.get(name, {}) + if isinstance(workspace_spec, dict): + package = workspace_spec.get("package", name) + if "path" in workspace_spec or package in workspace_packages: + return package + return name if name in workspace_packages else None + package = spec.get("package", name) + if "path" in spec and (crate_dir / spec["path"]).resolve().is_relative_to(workspace_root): + return package + return package if package in workspace_packages else None + + def collect(table: object) -> None: + if not isinstance(table, dict): + return + for name, spec in table.items(): + package = resolve(name, spec) + if package: + dependencies.add(package) + + collect(crate_data.get("dependencies", {})) + collect(crate_data.get("build-dependencies", {})) + for target in crate_data.get("target", {}).values(): + if isinstance(target, dict): + collect(target.get("dependencies", {})) + collect(target.get("build-dependencies", {})) + return dependencies + + +def validate_publish_order(args: object) -> int: + """Check that package publication order follows workspace dependencies.""" + manifest = load_manifest(Path(args.manifest)) + workspace_toml = Path(args.workspace_toml) + publishable = [crate for crate in manifest["crates"] if crate["publish"]] + order = {crate["package"]: crate["publish_order"] for crate in publishable} + violations = [] + for crate in publishable: + crate_toml = workspace_toml.parent / crate["cargo_toml"] + for dependency in sorted(workspace_dependency_names(crate_toml, workspace_toml)): + if dependency in order and order[crate["package"]] <= order[dependency]: + violations.append( + f"{crate['package']} (publish_order={order[crate['package']]}) depends on " + f"{dependency} (publish_order={order[dependency]})" + ) + if violations: + raise SystemExit("publish_order violation(s):\n - " + "\n - ".join(violations)) + print("ok: publish_order matches the workspace dependency graph") + return 0 + + +def workspace_version(workspace_toml: Path) -> str: + """Resolve the release version from the manifest-declared version source. + + [project].workspace_toml names the single version source for a consumer: + a Cargo workspace manifest ([workspace.package].version) for Rust + consumers, or a PEP 621 pyproject.toml ([project].version) for + pure-Python consumers with no Cargo workspace. + """ + data = tomllib.loads(workspace_toml.read_text(encoding="utf-8")) + cargo_version = data.get("workspace", {}).get("package", {}).get("version") + if isinstance(cargo_version, str) and cargo_version: + return cargo_version + project_version = data.get("project", {}).get("version") + if isinstance(project_version, str) and project_version: + return project_version + raise SystemExit( + f"{workspace_toml}: version source must declare [workspace.package].version " + "(Cargo workspace) or [project].version (pyproject)" + ) + + +def _resolve_workspace_path(workspace_toml: Path, relative_path: str) -> Path: + return workspace_toml.parent / relative_path + + +def _assert_workspace_inherited_version(workspace_toml: Path, relative_path: str, *, allow_literal_base: bool = False) -> None: + path = _resolve_workspace_path(workspace_toml, relative_path) + data = tomllib.loads(path.read_text(encoding="utf-8")) + value = data.get("package", {}).get("version") + if isinstance(value, dict) and value.get("workspace") is True: + return + if allow_literal_base and value == workspace_version(workspace_toml).split("-", 1)[0]: + return + if not isinstance(value, dict) or value.get("workspace") is not True: + raise SystemExit( + f"{relative_path}: [package].version must inherit workspace.package.version or match workspace base" + ) + + +def _assert_python_package_version( + workspace_toml: Path, + relative_path: str, + expected_version: str, + *, + cargo_manifest: str | None = None, +) -> None: + path = _resolve_workspace_path(workspace_toml, relative_path) + data = tomllib.loads(path.read_text(encoding="utf-8")) + actual_version = data.get("project", {}).get("version") + dynamic_version = actual_version is None and "version" in data.get("project", {}).get("dynamic", []) + if actual_version is None and "version" in data.get("project", {}).get("dynamic", []): + if cargo_manifest is None: + raise SystemExit(f"{relative_path}: dynamic version requires a Cargo manifest") + cargo_data = tomllib.loads(_resolve_workspace_path(workspace_toml, cargo_manifest).read_text(encoding="utf-8")) + actual_version = cargo_data.get("package", {}).get("version") + if isinstance(actual_version, dict) and actual_version.get("workspace") is True: + actual_version = workspace_version(workspace_toml) + expected = expected_version.split("-", 1)[0] if dynamic_version else expected_version + if actual_version != expected: + raise SystemExit( + f"{relative_path}: [project].version mismatch: " + f"expected {expected}, got {actual_version!r}" + ) + + +def _python_project_version(pyproject_toml: Path) -> str: + data = tomllib.loads(pyproject_toml.read_text(encoding="utf-8")) + project = data.get("project", {}) + version = project.get("version") + if version is None and "version" in project.get("dynamic", []): + return "" + if not isinstance(version, str): + raise SystemExit(f"{pyproject_toml}: [project].version must be a string") + return version + + +def _python_project_name(pyproject_toml: Path) -> str: + data = tomllib.loads(pyproject_toml.read_text(encoding="utf-8")) + project = data.get("project", {}) + name = project.get("name") + if not isinstance(name, str): + raise SystemExit(f"{pyproject_toml}: [project].name must be a string") + return name + + +SUPPORTED_PYTHON_BUILD_SYSTEMS = frozenset({"maturin", "setuptools"}) + + +def _python_distribution_build_system(distribution: dict) -> str: + """Resolve one distribution's build system; unsupported shapes fail closed.""" + name = distribution.get("name", "?") + cargo_manifest = distribution.get("cargo_manifest") + build_system = distribution.get("build_system") + if cargo_manifest and build_system: + raise SystemExit( + f"[[python_distributions]] {name}: must not set both cargo_manifest and build_system" + ) + if cargo_manifest: + return "maturin" + if build_system not in SUPPORTED_PYTHON_BUILD_SYSTEMS: + raise SystemExit( + f"[[python_distributions]] {name}: unsupported build_system {build_system!r}; " + "declare cargo_manifest (maturin) or build_system = \"setuptools\"" + ) + return build_system + + +def _python_distribution_entries(manifest: dict) -> list[dict]: + """Return normalized Python distribution entries from the release manifest.""" + packages = {entry["package"]: entry for entry in manifest["python_packages"]} + entries: list[dict] = [] + for distribution in manifest["python_distributions"]: + package = packages[distribution["name"]] + source = distribution["source"] + entries.append( + { + "artifact": package["artifact"], + "name": distribution["name"], + "source": source, + "pyproject": package["manifest"], + "cargo_manifest": distribution.get("cargo_manifest"), + "build_system": _python_distribution_build_system(distribution), + "module_path": distribution.get( + "module_path", f"{source}/python/{package['module']}" + ), + "sdist": distribution["sdist"], + "wheels": distribution["wheels"], + } + ) + return entries + + +def _python_distribution_expectations(manifest: dict) -> dict[str, dict[str, int]]: + return { + entry["name"]: { + "wheel": len(entry["wheels"]), + "sdist": int(entry["sdist"]), + } + for entry in _python_distribution_entries(manifest) + } + + +def _require_project(manifest: dict) -> dict: + project = manifest["project"] + _require_keys( + project, + ("name", "archive_prefix", "description", "homepage", "license"), + "[project]", + ) + return project + + +def _renderer_archive_path(manifest: dict) -> str: + value = _require_project(manifest).get("renderer_archive_path") + if not isinstance(value, str) or not value: + raise SystemExit("[project].renderer_archive_path must be a non-empty string") + return value + + +def _release_targets_by_name(manifest: dict) -> dict[str, dict]: + targets: dict[str, dict] = {} + for index, target in enumerate(manifest["release_targets"], start=1): + _require_keys(target, ("target", "os", "archive"), f"[[release_targets]] #{index}") + name = target["target"] + if name in targets: + raise SystemExit(f"duplicate release target: {name}") + targets[name] = target + if not targets: + raise SystemExit("manifest must define [[release_targets]]") + return targets + + +def _channel_config(manifest: dict, channel_name: str) -> dict: + try: + channel = manifest["channels"][channel_name] + except KeyError as error: + raise SystemExit(f"manifest must define [channels.{channel_name}]") from error + if not isinstance(channel, dict): + raise SystemExit(f"[channels.{channel_name}] must be a table") + return channel + + +def _is_prerelease_tag(tag: str) -> bool: + """Return whether a SemVer-style tag names a prerelease.""" + return "-" in tag.removeprefix("v").split("+", maxsplit=1)[0] + + +def _validate_homebrew_formulas( + channel: dict, available_binaries: set[str] | None = None +) -> list[dict]: + """Validate manifest-declared Homebrew formula entries.""" + formulas = channel.get("formulas") + if not isinstance(formulas, list) or not formulas: + raise SystemExit("[channels.homebrew] must define [[channels.homebrew.formulas]]") + + paths: set[str] = set() + for index, formula in enumerate(formulas, start=1): + label = f"[[channels.homebrew.formulas]] #{index}" + if not isinstance(formula, dict): + raise SystemExit(f"{label} must be a table") + _require_keys( + formula, + ("path", "template", "class", "test_command", "test_output", "release_track"), + label, + ) + for key in ("path", "template", "class", "test_command", "test_output"): + if not isinstance(formula[key], str) or not formula[key]: + raise SystemExit(f"{label}.{key} must be a non-empty string") + binaries = formula.get("binaries") + if binaries is None: + legacy_binary = formula.get("binary") + if not isinstance(legacy_binary, str) or not legacy_binary: + raise SystemExit(f"{label} must define non-empty binaries or legacy binary") + binaries = [legacy_binary] + formula["binaries"] = binaries + if not isinstance(binaries, list) or not binaries or not all( + isinstance(binary, str) and binary for binary in binaries + ): + raise SystemExit(f"{label}.binaries must be a non-empty list of strings") + if len(set(binaries)) != len(binaries): + raise SystemExit(f"{label}.binaries must not contain duplicates") + if available_binaries is not None: + missing_binaries = sorted(set(binaries) - available_binaries) + if missing_binaries: + raise SystemExit( + f"{label}.binaries references undeclared release binary(s): " + + ", ".join(missing_binaries) + ) + test_binary = formula.setdefault("test_binary", binaries[0]) + if not isinstance(test_binary, str) or test_binary not in binaries: + raise SystemExit(f"{label}.test_binary must name one of its binaries") + for key in ("path", "template"): + path = PurePosixPath(formula[key]) + if path.is_absolute() or ".." in path.parts or str(path) in ("", "."): + raise SystemExit(f"{label}.{key} must be a safe relative path") + if formula["release_track"] not in {"stable", "prerelease"}: + raise SystemExit(f"{label}.release_track must be stable or prerelease") + if formula["path"] in paths: + raise SystemExit(f"duplicate Homebrew formula path: {formula['path']}") + paths.add(formula["path"]) + + return formulas + + +def _homebrew_formulas_for_tag( + channel: dict, tag: str, available_binaries: set[str] | None = None +) -> list[dict]: + """Select manifest-declared Homebrew formulas for one release tag.""" + formulas = _validate_homebrew_formulas(channel, available_binaries) + selected_track = "prerelease" if _is_prerelease_tag(tag) else "stable" + selected = [ + formula for formula in formulas if formula["release_track"] == selected_track + ] + + if not selected: + raise SystemExit(f"no Homebrew {selected_track} formulas declared for tag {tag}") + return selected + + +def _normalize_pypi_name(name: str) -> str: + """Return the PEP 503 canonical project name used for public lookups.""" + return re.sub(r"[-_.]+", "-", name).lower() + + +def _url_from_contract(template: str, name: str, version: str) -> str: + return template.format(name=name, version=version) + + +def _public_registry_checks( + contracts: dict[str, dict], channel_name: str, name: str, version: str | None +) -> list[dict[str, str | None]]: + """Build contract-derived public registry checks for one candidate artifact.""" + try: + contract = contracts[channel_name] + except KeyError as error: + raise SystemExit(f"channel contract missing for {channel_name}") from error + if not contract.get("public_registry_checks", False): + raise SystemExit(f"{channel_name} does not support a public registry inquiry") + + normalized_name = _normalize_pypi_name(name) if channel_name == "pypi" else name + registry_contracts: list[dict[str, str]] + if channel_name == "crates_io": + registry_contracts = [ + { + "name": "crates.io", + "project_lookup_url": contract["project_lookup_url"], + "version_lookup_url": contract["version_lookup_url"], + "version_policy": "must_be_absent", + } + ] + else: + registry_contracts = contract.get("registries", []) + + checks: list[dict[str, str]] = [] + for registry in registry_contracts: + check: dict[str, str | None] = { + "channel": channel_name, + "agent": contract["agent"], + "registry": registry["name"], + "name": name, + "normalized_name": normalized_name, + "expected_version": version, + "project_lookup_url": _url_from_contract( + registry["project_lookup_url"], normalized_name, version or "" + ), + "version_lookup_url": ( + _url_from_contract(registry["version_lookup_url"], normalized_name, version) + if version + else None + ), + "version_policy": registry["version_policy"], + } + checks.append(check) + return checks + + +def registry_version_state(url: str, timeout: int = 20) -> str: + """Resolve an exact version_lookup_url to published/absent; fail closed otherwise.""" + import urllib.error + import urllib.request + + request = urllib.request.Request(url, headers={"User-Agent": "sc-publish-kit"}) + try: + with urllib.request.urlopen(request, timeout=timeout) as response: + status = response.status + except urllib.error.HTTPError as error: + status = error.code + except (urllib.error.URLError, TimeoutError) as error: + raise SystemExit(f"registry lookup failed for {url}: {error}") from error + if status == 200: + return "published" + if status == 404: + return "absent" + raise SystemExit(f"registry state for {url} is indeterminate (status {status})") + + +def check_version_publication( + manifest_path: Path, version: str, already_published_channels: str +) -> tuple[list[str], list[str]]: + """Return unexpected and explicitly preserved published crate artifacts.""" + manifest = load_manifest(manifest_path, with_channel_contracts=True) + preserved_channels = { + channel.strip() for channel in already_published_channels.split(",") if channel.strip() + } + unexpected, preserved = [], [] + for crate in manifest["crates"]: + check = _public_registry_checks( + manifest["channel_contracts"], "crates_io", crate["package"], version + )[0] + if registry_version_state(check["version_lookup_url"]) == "published": + (preserved if check["channel"] in preserved_channels else unexpected).append( + crate["artifact"] + ) + return unexpected, preserved + + +def _channel_contract(manifest: dict, channel_name: str) -> dict: + try: + contract = manifest["channel_contracts"][channel_name] + except KeyError as error: + raise SystemExit(f"channel contract missing for {channel_name}") from error + return contract + + +def _channel_names(manifest: dict) -> tuple[str, ...]: + channels = manifest["channels"] + if not isinstance(channels, dict): + raise SystemExit("[channels] must be a table") + contracts = manifest["channel_contracts"] + post_release_channels = { + name for name, contract in contracts.items() if contract["stage"] == "post_release" + } + unknown = sorted(set(channels) - post_release_channels) + if unknown: + raise SystemExit("unsupported release channel(s): " + ", ".join(unknown)) + # An empty table set is valid: post-release channels are opt-in per + # consumer; the root channels are contract-required instead. + return tuple(channels) + + +def _preflight_outcome_status(outcome: str | None) -> str: + """Map a GitHub Actions step outcome to a non-disclosing check status.""" + if outcome == "success": + return "passed" + if outcome in ("failure", "cancelled"): + return "failed" + return "blocked" + + +def _channel_outcome( + outcomes: dict[str, object], key: str, channel_name: str, fallback_key: str | None = None +) -> str | None: + """Read a channel-specific outcome, retaining legacy scalar compatibility.""" + outcome = outcomes.get(key) + if isinstance(outcome, dict): + channel_outcome = outcome.get(channel_name) + return channel_outcome if isinstance(channel_outcome, str) else None + if isinstance(outcome, str): + return outcome + if fallback_key is not None: + fallback = outcomes.get(fallback_key) + return fallback if isinstance(fallback, str) else None + return None + + +def _channel_preflight_result( + channel: dict[str, object], outcomes: dict[str, object], tag: str | None +) -> dict[str, object]: + """Materialize one worker result from its contract and check outcomes.""" + checks: list[dict[str, object]] = [] + channel_name = str(channel["name"]) + for requirement, outcome_key in ( + ("publisher ownership", "ownership"), + ("normalized release tag", "release_metadata"), + ): + checks.append({ + "kind": "release_authorization", + "requirements": [requirement], + "status": _preflight_outcome_status(outcomes.get(outcome_key)), + }) + for key, outcome_key, fallback_key in ( + ("repository_secrets", "repository_secret_channels", "repository_secrets"), + ("environment_secrets", "environment_secrets", None), + ("liveness_checks", "credential_liveness_channels", "credential_liveness"), + ("github_actions_permissions", "github_release_permissions", None), + ("public_registry_checks", "registry_state", None), + ): + requirements = channel.get(key, []) + if requirements: + outcome = _channel_outcome( + outcomes, outcome_key, channel_name, fallback_key + ) + checks.append({ + "kind": key, + "requirements": requirements, + "status": _preflight_outcome_status(outcome), + }) + rehearsal = channel.get("credential_rehearsal") + statuses = [check["status"] for check in checks] + if "failed" in statuses: + status, diagnostic = "failed", "PREFLIGHT.CHECK_FAILED" + elif "blocked" in statuses: + status, diagnostic = "blocked", "PREFLIGHT.CHECK_BLOCKED" + else: + status, diagnostic = "passed", "" + return { + "name": channel["name"], + "agent": channel["agent"], + "tag": tag, + "status": status, + "checks": checks, + "credential_rehearsal": rehearsal, + "sanitized_diagnostic": diagnostic, + } diff --git a/.github/scripts/release_registry.py b/.github/scripts/release_registry.py new file mode 100644 index 00000000..a5061da6 --- /dev/null +++ b/.github/scripts/release_registry.py @@ -0,0 +1,29 @@ +from __future__ import annotations + +import argparse +from pathlib import Path + +from release_manifest import check_version_publication, registry_version_state + + +def cmd_check_version_unpublished(args: argparse.Namespace) -> int: + """Detect already-published crates via the contract's exact version lookup.""" + unexpected, preserved = check_version_publication( + Path(args.manifest), args.version, args.already_published_channels + ) + if unexpected: + raise SystemExit("release version already published for: " + ", ".join(sorted(unexpected))) + if preserved: + print( + "ok: crates_io is preserved from a prior release run; version already published for: " + + ", ".join(sorted(preserved)) + ) + return 0 + print(f"ok: no publishable artifacts found at version {args.version}") + return 0 + + +def cmd_registry_status(args: argparse.Namespace) -> int: + """Resolve one public-registry URL to published or absent, failing closed.""" + print(registry_version_state(args.url, timeout=args.timeout)) + return 0 diff --git a/.github/scripts/tests/test_fail_closed_probes.py b/.github/scripts/tests/test_fail_closed_probes.py new file mode 100644 index 00000000..26099003 --- /dev/null +++ b/.github/scripts/tests/test_fail_closed_probes.py @@ -0,0 +1,329 @@ +"""Behavioral fail-closed tests for the release and channel idempotency probes. + +Each test extracts the real bash from the vendored workflow/action YAML and +executes it against a stubbed `gh`, asserting that only a confirmed 404 +("absent") proceeds while any indeterminate probe result hard-fails. +""" + +from __future__ import annotations + +import os +import stat +import subprocess +from pathlib import Path + + +REPO_ROOT = next( + path for path in Path(__file__).resolve().parents if (path / "install.py").is_file() +) + + +def extract_run_block(text: str, anchor: str) -> str: + """Return the dedented bash of the first `run: |` block after anchor.""" + anchor_index = text.index(anchor) + run_index = text.index("run: |", anchor_index) + line_start = text.rindex("\n", 0, run_index) + 1 + block_indent = " " * (run_index - line_start + 2) + lines: list[str] = [] + for line in text[text.index("\n", run_index) + 1 :].splitlines(): + if not line.strip(): + lines.append("") + continue + if not line.startswith(block_indent): + break + lines.append(line[len(block_indent) :]) + return "\n".join(lines) + "\n" + + +def write_gh_stub(tmp_path: Path, body: str) -> Path: + bin_dir = tmp_path / "stub-bin" + bin_dir.mkdir(exist_ok=True) + stub = bin_dir / "gh" + stub.write_text("#!/usr/bin/env bash\n" + body, encoding="utf-8") + stub.chmod(stub.stat().st_mode | stat.S_IEXEC) + return bin_dir + + +def run_probe_script( + tmp_path: Path, script: str, stub_body: str, env: dict[str, str] +) -> tuple[subprocess.CompletedProcess[str], dict[str, str]]: + bin_dir = write_gh_stub(tmp_path, stub_body) + output_file = tmp_path / "github-output" + output_file.write_text("", encoding="utf-8") + result = subprocess.run( + ["bash"], + input=script, + text=True, + capture_output=True, + check=False, + env={ + **os.environ, + "PATH": f"{bin_dir}:{os.environ['PATH']}", + "GH_TOKEN": "stub-token", + "GITHUB_OUTPUT": str(output_file), + **env, + }, + ) + outputs = dict( + line.split("=", 1) + for line in output_file.read_text(encoding="utf-8").splitlines() + if "=" in line + ) + return result, outputs + + +# --- GitHub Release probe (verify-published-release, issue #40) ------------- + + +RELEASE_GH_STUB = """ +case "${FAKE_GH_MODE:?}" in + found) + printf '%s' "${FAKE_GH_RELEASE_JSON}" + exit 0 + ;; + absent) + echo "release not found" >&2 + exit 1 + ;; + http-404) + echo "gh: Not Found (HTTP 404)" >&2 + exit 1 + ;; + transient) + echo "gh: The server had an error while processing your request (HTTP 502)" >&2 + exit 1 + ;; +esac +exit 1 +""" + +COMPLETE_RELEASE_JSON = ( + '{"isDraft": false, "assets": [' + '{"name": "example_1.2.3_x86_64-unknown-linux-gnu.tar.gz"},' + '{"name": "checksums.txt"}]}' +) +RELEASE_ASSET_PATTERNS = "^example_.*\\.tar\\.gz$\n^checksums\\.txt$" + + +def release_probe_script() -> str: + text = ( + REPO_ROOT / ".github" / "actions" / "verify-published-release" / "action.yml" + ).read_text(encoding="utf-8") + return extract_run_block(text, "id: verify") + + +def run_release_probe( + tmp_path: Path, + *, + mode: str, + probe: str, + release_json: str = "", + asset_patterns: str = RELEASE_ASSET_PATTERNS, + tag: str = "v1.2.3", +) -> tuple[subprocess.CompletedProcess[str], dict[str, str]]: + return run_probe_script( + tmp_path, + release_probe_script(), + RELEASE_GH_STUB, + { + "FAKE_GH_MODE": mode, + "FAKE_GH_RELEASE_JSON": release_json, + "RELEASE_REPOSITORY": "example/example", + "RELEASE_TAG": tag, + "REQUIRED_ASSET_PATTERNS": asset_patterns, + "PROBE_MODE": probe, + }, + ) + + +def test_release_probe_confirmed_absent_reports_absent(tmp_path: Path) -> None: + for mode in ("absent", "http-404"): + result, outputs = run_release_probe(tmp_path, mode=mode, probe="true") + assert result.returncode == 0, result.stderr + assert outputs["release_state"] == "absent" + + +def test_release_probe_fails_closed_on_transient_api_error(tmp_path: Path) -> None: + result, outputs = run_release_probe(tmp_path, mode="transient", probe="true") + assert result.returncode != 0 + assert "indeterminate" in result.stderr + assert "HTTP 502" in result.stderr + assert "release_state" not in outputs + + +def test_release_probe_reports_complete_when_every_asset_is_present(tmp_path: Path) -> None: + result, outputs = run_release_probe( + tmp_path, mode="found", probe="true", release_json=COMPLETE_RELEASE_JSON + ) + assert result.returncode == 0, result.stderr + assert outputs["release_state"] == "complete" + + +def test_release_probe_reports_incomplete_for_missing_assets_or_draft(tmp_path: Path) -> None: + missing_assets_json = '{"isDraft": false, "assets": [{"name": "checksums.txt"}]}' + result, outputs = run_release_probe( + tmp_path, mode="found", probe="true", release_json=missing_assets_json + ) + assert result.returncode == 0, result.stderr + assert outputs["release_state"] == "incomplete" + assert "missing an asset matching" in result.stderr + + draft_json = COMPLETE_RELEASE_JSON.replace('"isDraft": false', '"isDraft": true') + result, outputs = run_release_probe( + tmp_path, mode="found", probe="true", release_json=draft_json + ) + assert result.returncode == 0, result.stderr + assert outputs["release_state"] == "incomplete" + + +def test_release_verify_mode_still_hard_fails_each_bad_state(tmp_path: Path) -> None: + result, _ = run_release_probe(tmp_path, mode="absent", probe="false") + assert result.returncode != 0 + assert "No published GitHub Release found" in result.stderr + + result, outputs = run_release_probe(tmp_path, mode="transient", probe="false") + assert result.returncode != 0 + assert "indeterminate" in result.stderr + assert "release_state" not in outputs + + draft_json = COMPLETE_RELEASE_JSON.replace('"isDraft": false', '"isDraft": true') + result, _ = run_release_probe( + tmp_path, mode="found", probe="false", release_json=draft_json + ) + assert result.returncode != 0 + assert "is still a draft" in result.stderr + + missing_assets_json = '{"isDraft": false, "assets": [{"name": "checksums.txt"}]}' + result, _ = run_release_probe( + tmp_path, mode="found", probe="false", release_json=missing_assets_json + ) + assert result.returncode != 0 + assert "missing an asset matching" in result.stderr + + result, outputs = run_release_probe( + tmp_path, mode="found", probe="false", release_json=COMPLETE_RELEASE_JSON + ) + assert result.returncode == 0, result.stderr + assert outputs["release_state"] == "complete" + + +def test_release_probe_rejects_invalid_tags_before_any_lookup(tmp_path: Path) -> None: + result, outputs = run_release_probe( + tmp_path, mode="found", probe="true", tag="not-a-tag" + ) + assert result.returncode != 0 + assert "Invalid tag" in result.stderr + assert not outputs + + +# --- winget-pkgs pre-submission probe (winget-publish.yml, issue #41) ------- + + +WINGET_GH_STUB = """ +args="$*" +case "${args}" in + *search/issues*) + case "${FAKE_GH_SEARCH_MODE:?}" in + none) + printf '{"total_count": 0, "incomplete_results": false}' + exit 0 + ;; + found) + printf '{"total_count": 2, "incomplete_results": false}' + exit 0 + ;; + incomplete) + printf '{"total_count": 0, "incomplete_results": true}' + exit 0 + ;; + error) + echo "gh: HTTP 503 service unavailable" >&2 + exit 1 + ;; + esac + ;; + *winget-pkgs/contents/*) + case "${FAKE_GH_CONTENTS_MODE:?}" in + found) + printf '{"name": "manifest"}' + exit 0 + ;; + absent) + echo "gh: Not Found (HTTP 404)" >&2 + exit 1 + ;; + error) + echo "gh: The server had an error while processing your request (HTTP 502)" >&2 + exit 1 + ;; + esac + ;; +esac +exit 1 +""" + + +def winget_probe_script() -> str: + text = (REPO_ROOT / ".github" / "workflows" / "winget-publish.yml").read_text( + encoding="utf-8" + ) + return extract_run_block(text, "id: winget_probe") + + +def run_winget_probe( + tmp_path: Path, *, contents_mode: str, search_mode: str = "none" +) -> tuple[subprocess.CompletedProcess[str], dict[str, str]]: + return run_probe_script( + tmp_path, + winget_probe_script(), + WINGET_GH_STUB, + { + "FAKE_GH_CONTENTS_MODE": contents_mode, + "FAKE_GH_SEARCH_MODE": search_mode, + "IDENTIFIER": "Example.Example", + "RELEASE_TAG": "v1.2.3", + }, + ) + + +def test_winget_probe_confirmed_absent_proceeds_to_submission(tmp_path: Path) -> None: + result, outputs = run_winget_probe(tmp_path, contents_mode="absent", search_mode="none") + assert result.returncode == 0, result.stderr + assert outputs["already_published"] == "false" + + +def test_winget_probe_skips_when_manifest_or_pr_already_exists(tmp_path: Path) -> None: + result, outputs = run_winget_probe(tmp_path, contents_mode="found") + assert result.returncode == 0, result.stderr + assert outputs["already_published"] == "true" + assert "already publishes" in result.stdout + + result, outputs = run_winget_probe( + tmp_path, contents_mode="absent", search_mode="found" + ) + assert result.returncode == 0, result.stderr + assert outputs["already_published"] == "true" + assert "pull request (open or merged) already exists" in result.stdout + + +def test_winget_probe_fails_closed_on_transient_manifest_lookup_error(tmp_path: Path) -> None: + result, outputs = run_winget_probe(tmp_path, contents_mode="error") + assert result.returncode != 0 + assert "indeterminate" in result.stderr + assert "HTTP 502" in result.stderr + assert "already_published" not in outputs + + +def test_winget_probe_fails_closed_on_pr_search_failures(tmp_path: Path) -> None: + result, outputs = run_winget_probe( + tmp_path, contents_mode="absent", search_mode="error" + ) + assert result.returncode != 0 + assert "already_published" not in outputs + + result, outputs = run_winget_probe( + tmp_path, contents_mode="absent", search_mode="incomplete" + ) + assert result.returncode != 0 + assert "incomplete results" in result.stderr + assert "already_published" not in outputs diff --git a/.github/scripts/tests/test_install.py b/.github/scripts/tests/test_install.py new file mode 100644 index 00000000..6966a8ae --- /dev/null +++ b/.github/scripts/tests/test_install.py @@ -0,0 +1,437 @@ +"""Unit tests for the repository-neutral publish-kit installer.""" + +from __future__ import annotations + +import importlib.util +import json +import re +import subprocess +import sys +import tempfile +import tomllib +import unittest +from pathlib import Path +from unittest.mock import patch + + +INSTALLER = next(path / "install.py" for path in Path(__file__).resolve().parents if (path / "install.py").is_file()) +SPEC = importlib.util.spec_from_file_location("sc_publish_install", INSTALLER) +assert SPEC is not None and SPEC.loader is not None +INSTALL = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(INSTALL) + + +class InstallValuesTests(unittest.TestCase): + @staticmethod + def valid_values() -> dict[str, object]: + return { + "schema_version": 1, + "project": { + "name": "example", + "archive_prefix": "example", + "description": "Example release package", + "homepage": "https://example.test/example", + "license": "MIT", + "readme_dependency_crate": "example-core", + "renderer_archive_path": "bin/example", + }, + "release_targets": [ + {"target": "x86_64-unknown-linux-gnu", "os": "ubuntu-latest", "archive": "tar.gz"} + ], + "crates": [ + { + "artifact": "example-core", + "package": "example-core", + "cargo_toml": "crates/example-core/Cargo.toml", + "publish": True, + "publish_order": 1, + "wait_after_publish_seconds": 0, + }, + { + "artifact": "example-python", + "package": "example-python", + "cargo_toml": "crates/example-python/Cargo.toml", + "publish": False, + "publish_order": 0, + "wait_after_publish_seconds": 0, + }, + ], + "release_binaries": [ + { + "name": "example", + "bundled_paths": [ + { + "source": "docs", + "destination": "share/doc/example", + "homebrew_destination_components": ["pkgshare"], + } + ], + } + ], + "python_packages": [ + { + "artifact": "example-wheel", + "package": "example", + "manifest": "python/pyproject.toml", + "module": "example", + "publish": "pypi", + } + ], + "python_distributions": [ + { + "name": "example", + "source": "python", + "cargo_manifest": "crates/example-python/Cargo.toml", + "module_path": "python/example", + "sdist": True, + "wheels": ["ubuntu-latest", "macos-latest", "windows-latest"], + }, + { + "name": "example-plugin", + "source": "plugin", + "build_system": "setuptools", + "module_path": "plugin/src/example_plugin", + "sdist": True, + "wheels": ["ubuntu-latest"], + }, + ], + "channels": { + "pypi": { + "workflow": "pypi-publish.yml", + "dispatch_inputs": {"target": "production"}, + "credential_rehearsal_inputs": {"target": "testpypi"}, + "test_repository": "testpypi", + "production_repository": "pypi", + }, + "homebrew": { + "workflow": "homebrew-publish.yml", + "dispatch_inputs": {}, + "tap_repository": "example/tap", + "renderer_target": "x86_64-unknown-linux-gnu", + "formulas": [ + { + "path": "Formula/example.rb", + "template": "release/homebrew/formula.rb.j2", + "class": "Example", + "binaries": ["example"], + "test_binary": "example", + "test_command": "--help", + "test_output": "Example release package", + "release_track": "stable", + } + ], + "assets": [ + {"key": "macos_arm", "target": "aarch64-apple-darwin"}, + {"key": "macos_intel", "target": "x86_64-apple-darwin"}, + {"key": "linux", "target": "x86_64-unknown-linux-gnu"}, + ], + }, + "winget": { + "workflow": "winget-publish.yml", + "dispatch_inputs": {}, + "identifier": "example.example", + "installer_target": "x86_64-pc-windows-msvc", + }, + "scoop": { + "workflow": "scoop-publish.yml", + "dispatch_inputs": {}, + "bucket_repository": "example/scoop-bucket", + "manifest_path": "bucket/example.json", + "manifest_template": "release/scoop/manifest.json.j2", + "installer_target": "x86_64-pc-windows-msvc", + "binary": "bin/example.exe", + "renderer_target": "x86_64-unknown-linux-gnu", + }, + }, + } + + def test_help_explains_the_explicit_install_workflow(self) -> None: + result = subprocess.run( + [sys.executable, "-S", str(INSTALLER), "--help"], + check=False, + capture_output=True, + text=True, + ) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("--input INSTALL.json", result.stdout) + self.assertIn("workflow:", result.stdout) + self.assertIn("caller-owned complete JSON input", result.stdout) + + def test_load_install_values_accepts_the_complete_manifest_contract(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + path = root / "input.json" + values = self.valid_values() + path.write_text(json.dumps(values), encoding="utf-8") + loaded = INSTALL.load_install_values(path) + self.assertEqual(loaded, values) + self.assertEqual(set(loaded["channels"]), set(INSTALL.CHANNEL_NAMES)) + self.assertEqual(loaded["python_distributions"][1]["build_system"], "setuptools") + + def test_load_install_values_rejects_invalid_publish_orders(self) -> None: + cases = { + "boolean": (True, "integer"), + "zero": (0, "positive when publish is true"), + "negative": (-1, "non-negative integer"), + } + for name, (publish_order, message) in cases.items(): + with self.subTest(name=name), tempfile.TemporaryDirectory() as directory: + values = self.valid_values() + values["crates"][0]["publish_order"] = publish_order + path = Path(directory) / "input.json" + path.write_text(json.dumps(values), encoding="utf-8") + with self.assertRaisesRegex(Exception, message): + INSTALL.load_install_values(path) + with tempfile.TemporaryDirectory() as directory: + values = self.valid_values() + values["crates"][1].update(publish=True, publish_order=1) + path = Path(directory) / "input.json" + path.write_text(json.dumps(values), encoding="utf-8") + with self.assertRaisesRegex(Exception, "unique"): + INSTALL.load_install_values(path) + + def test_load_install_values_rejects_missing_complete_contract_fields(self) -> None: + values = self.valid_values() + del values["project"]["license"] + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "input.json" + path.write_text(json.dumps(values), encoding="utf-8") + with self.assertRaisesRegex(Exception, "project.license"): + INSTALL.load_install_values(path) + + def test_load_install_values_accepts_a_channel_subset(self) -> None: + values = self.valid_values() + for name in ("homebrew", "winget", "scoop"): + del values["channels"][name] + # renderer_archive_path is only required for homebrew/scoop consumers. + del values["project"]["renderer_archive_path"] + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "input.json" + path.write_text(json.dumps(values), encoding="utf-8") + loaded = INSTALL.load_install_values(path) + self.assertEqual(set(loaded["channels"]), {"pypi"}) + template = INSTALL.template_values(loaded) + self.assertTrue(template["has_channel_pypi"]) + for name in ("homebrew", "winget", "scoop"): + self.assertFalse(template[f"has_channel_{name}"]) + self.assertIn(name, template["channels"]) + + def test_load_install_values_rejects_unknown_channel_names(self) -> None: + values = self.valid_values() + values["channels"]["npm"] = {"workflow": "npm.yml", "dispatch_inputs": {}} + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "input.json" + path.write_text(json.dumps(values), encoding="utf-8") + with self.assertRaisesRegex(Exception, "unsupported name"): + INSTALL.load_install_values(path) + + def test_render_omits_undeclared_channel_tables(self) -> None: + try: + import sc_compose # noqa: F401 + except ModuleNotFoundError: + self.skipTest("sc-compose bindings are not provisioned in this environment") + values = self.valid_values() + for name in ("homebrew", "winget", "scoop"): + del values["channels"][name] + del values["project"]["renderer_archive_path"] + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "input.json" + path.write_text(json.dumps(values), encoding="utf-8") + loaded = INSTALL.load_install_values(path) + output = Path(directory) / "publish-artifacts.toml" + INSTALL.render_template( + Path("release/publish-artifacts.toml.j2"), loaded, output + ) + manifest = tomllib.loads(output.read_text(encoding="utf-8")) + self.assertEqual(set(manifest["channels"]), {"pypi"}) + self.assertEqual(manifest["channels"]["pypi"]["production_repository"], "pypi") + self.assertEqual( + manifest["python_distributions"][1]["build_system"], "setuptools" + ) + self.assertNotIn("renderer_archive_path", manifest["project"]) + + def test_load_install_values_rejects_ambiguous_python_distribution(self) -> None: + values = self.valid_values() + values["python_distributions"][0]["build_system"] = "setuptools" + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "input.json" + path.write_text(json.dumps(values), encoding="utf-8") + with self.assertRaisesRegex(Exception, "must not set both"): + INSTALL.load_install_values(path) + + def test_install_places_executable_release_helpers_at_every_workflow_path(self) -> None: + """The byte-exact overlay keeps helpers under .github/scripts/. + + This exercises a real install into a temporary consumer. It prevents a + parity-only check from accepting workflows that still call an obsolete + consumer-local scripts/ path. + """ + + if not (INSTALLER.parent / ".sc-publish-source-root").is_file(): + self.skipTest("installed consumers do not re-run the package source installer") + + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + consumer = root / "consumer" + consumer.mkdir() + input_path = root / "install.json" + input_path.write_text(json.dumps(self.valid_values()), encoding="utf-8") + + def render_empty_toml(_template: Path, _values: dict[str, object], output: Path) -> None: + output.write_text("schema_version = 1\n", encoding="utf-8") + + with ( + patch.object(INSTALL, "render_template", side_effect=render_empty_toml), + patch.object(sys, "argv", [str(INSTALLER), "--input", str(input_path), str(consumer)]), + ): + self.assertEqual(INSTALL.main(), 0) + + artifacts = consumer / ".github" / "scripts" / "release_artifacts.py" + gate = consumer / ".github" / "scripts" / "release_gate.sh" + self.assertTrue(artifacts.is_file()) + self.assertTrue(gate.is_file()) + + # The kit README installs under a kit-owned name and never + # overwrites the consumer repository's own README.md. + kit_readme = consumer / "README.sc-publish.md" + self.assertTrue(kit_readme.is_file()) + self.assertFalse((consumer / "README.md").exists()) + self.assertEqual( + kit_readme.read_bytes(), (INSTALLER.parent / "README.md").read_bytes() + ) + readme_text = kit_readme.read_text(encoding="utf-8") + self.assertIn("byte-for-byte", readme_text) + self.assertIn("bootstrap_sc_compose.py --venv", readme_text) + self.assertIn("--dry-run --input", readme_text) + self.assertIn(".claude/agents/publisher.md", readme_text) + self.assertIn(".cursor/", readme_text) + self.assertIn("idempotent", readme_text) + + workflows = ( + "release.yml", + "release-preflight.yml", + "pypi-publish.yml", + "homebrew-publish.yml", + "scoop-publish.yml", + "winget-publish.yml", + ) + for name in workflows: + text = (consumer / ".github" / "workflows" / name).read_text(encoding="utf-8") + self.assertIn(".github/scripts/release_artifacts.py", text, name) + self.assertNotIn("python3 scripts/release_artifacts.py", text, name) + release = (consumer / ".github" / "workflows" / "release.yml").read_text(encoding="utf-8") + release_candidate = ( + consumer / ".github" / "workflows" / "release-candidate.yml" + ).read_text(encoding="utf-8") + preflight = (consumer / ".github" / "workflows" / "release-preflight.yml").read_text( + encoding="utf-8" + ) + self.assertIn(".github/scripts/release_gate.sh", release) + self.assertNotIn("run: scripts/release_gate.sh", release) + self.assertIn('tag -a "${candidate_tag}" origin/develop', release_candidate) + self.assertIn('-c user.name="github-actions[bot]"', release_candidate) + self.assertIn(".github/scripts/release_artifacts.py validate-publish-order", preflight) + self.assertNotIn("scripts/ci/validate_publish_order.sh", preflight) + self.assertNotIn("docs/publishing-agent.md", preflight) + packaged_runtime_files = ( + list((consumer / ".github" / "workflows").glob("*.yml")) + + list((consumer / ".github" / "scripts").glob("*.py")) + + list((consumer / ".github" / "scripts").glob("*.sh")) + + list((consumer / ".github" / "actions").rglob("action.yml")) + ) + for path in packaged_runtime_files: + self.assertNotRegex( + path.read_text(encoding="utf-8"), + r"(? None: + for name in ( + "crates-io-publisher", + "github-release-publisher", + "pypi-publisher", + "homebrew-publisher", + "scoop-publisher", + "winget-publisher", + ): + text = (AGENTS / f"{name}.md").read_text(encoding="utf-8") + self.assertIn("version: 0.1.0", text) + self.assertIn("spawn_policy: background_agent_required", text) + self.assertIn("publisher-channel-protocol.md", text) + + def test_publisher_is_the_only_named_orchestrator(self) -> None: + text = (AGENTS / "publisher.md").read_text(encoding="utf-8") + self.assertIn("version: 1.6.6", text) + self.assertIn("spawn_policy: named_teammate_required", text) + self.assertIn("role-specific background workers", text) + self.assertIn("Never ask whether a token exists", text) + + def test_release_candidate_workflow_and_shared_policy_require_provenance(self) -> None: + workflow = (PACKAGE_ROOT / ".github" / "workflows" / "release-candidate.yml").read_text( + encoding="utf-8" + ) + gate = (PACKAGE_ROOT / ".github" / "scripts" / "release_gate.sh").read_text( + encoding="utf-8" + ) + policy = (PUBLISHING / "ref" / "release-state-strategy.md").read_text(encoding="utf-8") + self.assertIn("tag -a \"${candidate_tag}\" origin/develop", workflow) + self.assertIn("-c user.name=\"github-actions[bot]\"", workflow) + self.assertIn( + "-c user.email=\"41898282+github-actions[bot]@users.noreply.github.com\"", + workflow, + ) + self.assertIn("git merge-base --is-ancestor \"${candidate_tag}\" origin/develop", workflow) + self.assertIn("release-candidate-v", gate) + self.assertIn("git merge-base --is-ancestor \"$RELEASE_CANDIDATE_TAG\" \"$RELEASE_REF\"", gate) + self.assertNotIn("git diff --quiet \"$MAIN_REF\" \"$DEVELOP_REF\"", gate) + self.assertIn("Candidate Cut and Post-Cut Drift", policy) + self.assertIn("do not delay the\nrelease", policy) + + def test_task_templates_require_a_recipient(self) -> None: + for name in ("preflight.xml.j2", "publish.xml.j2"): + text = (PUBLISHING / name).read_text(encoding="utf-8") + self.assertIn("version: 0.1.0", text) + self.assertIn("- recipient", text) + self.assertIn("{{ recipient }}", text) + + def test_shared_publishing_documents_use_the_packaged_release_artifacts_script(self) -> None: + """Prompt commands must match the path the installer copies to consumers.""" + canonical_script = PACKAGE_ROOT / ".github" / "scripts" / "release_artifacts.py" + self.assertTrue(canonical_script.is_file()) + canonical_gate = PACKAGE_ROOT / ".github" / "scripts" / "release_gate.sh" + self.assertTrue(canonical_gate.is_file()) + + shared_documents = ( + AGENTS / "publisher.md", + PUBLISHING / "SKILL.md", + PUBLISHING / "ref" / "channel-contracts.md", + PACKAGE_ROOT / ".cursor" / "agents" / "publisher.md", + PACKAGE_ROOT / ".cursor" / "commands" / "cursor-publish.md", + PACKAGE_ROOT / ".cursor" / "skills" / "cursor-publish" / "SKILL.md", + ) + for document in shared_documents: + text = document.read_text(encoding="utf-8") + self.assertIn(".github/scripts/release_artifacts.py", text, document) + self.assertNotRegex( + text, + r"(? None: + homebrew = PACKAGE_ROOT / "release" / "homebrew" / "formula.rb.j2" + scoop = PACKAGE_ROOT / "release" / "scoop" / "manifest.json.j2" + self.assertTrue(homebrew.is_file()) + self.assertTrue(scoop.is_file()) + self.assertIn("{{ formula_class }}", homebrew.read_text(encoding="utf-8")) + self.assertIn("{{ windows_url | tojson }}", scoop.read_text(encoding="utf-8")) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/scripts/tests/test_publish_kit_scripts.py b/.github/scripts/tests/test_publish_kit_scripts.py new file mode 100644 index 00000000..6fe8dca8 --- /dev/null +++ b/.github/scripts/tests/test_publish_kit_scripts.py @@ -0,0 +1,378 @@ +"""Package-native unit tests for the vendored GitHub scripts.""" + +from __future__ import annotations + +import importlib.util +import os +import re +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + + +PACKAGE_ROOT = next(path for path in Path(__file__).resolve().parents if (path / "install.py").is_file()) +SCRIPTS = PACKAGE_ROOT / ".github" / "scripts" +sys.path.insert(0, str(SCRIPTS)) +import release_manifest # noqa: E402 + +BOOTSTRAP_SPEC = importlib.util.spec_from_file_location( + "bootstrap_sc_compose", SCRIPTS / "bootstrap_sc_compose.py" +) +assert BOOTSTRAP_SPEC is not None and BOOTSTRAP_SPEC.loader is not None +BOOTSTRAP = importlib.util.module_from_spec(BOOTSTRAP_SPEC) +BOOTSTRAP_SPEC.loader.exec_module(BOOTSTRAP) + + +class ReleaseManifestTests(unittest.TestCase): + def test_channel_contracts_describe_all_six_workers(self) -> None: + contracts = release_manifest.load_channel_contracts( + PACKAGE_ROOT / "release" / "publish-channel-contracts.toml.j2" + ) + self.assertEqual( + {contract["agent"] for contract in contracts.values()}, + { + "crates-io-publisher", + "github-release-publisher", + "pypi-publisher", + "homebrew-publisher", + "scoop-publisher", + "winget-publisher", + }, + ) + self.assertEqual(contracts["pypi"]["stage"], "post_release") + self.assertEqual(contracts["crates_io"]["repository_secrets"], ["CARGO_REGISTRY_TOKEN"]) + + def test_load_manifest_orders_crates_by_publish_order(self) -> None: + with tempfile.TemporaryDirectory() as directory: + manifest = Path(directory) / "publish-artifacts.toml" + manifest.write_text( + """[[crates]] +artifact = "late" +package = "late" +publish_order = 2 + +[[crates]] +artifact = "first" +package = "first" +publish_order = 1 + +[[release_binaries]] +name = "example" +""", + encoding="utf-8", + ) + loaded = release_manifest.load_manifest(manifest) + self.assertEqual([crate["artifact"] for crate in loaded["crates"]], ["first", "late"]) + self.assertEqual(loaded["release_binaries"], [{"name": "example"}]) + + +class RegistryVersionStateTests(unittest.TestCase): + def test_lookup_statuses_map_to_published_absent_or_fail_closed(self) -> None: + import urllib.error + from unittest.mock import patch + + class FakeResponse: + def __init__(self, status: int) -> None: + self.status = status + + def __enter__(self) -> "FakeResponse": + return self + + def __exit__(self, *args: object) -> bool: + return False + + def http_error(code: int) -> urllib.error.HTTPError: + return urllib.error.HTTPError("https://registry.invalid", code, "", {}, None) + + with patch("urllib.request.urlopen", return_value=FakeResponse(200)): + self.assertEqual( + release_manifest.registry_version_state("https://registry.invalid"), + "published", + ) + with patch("urllib.request.urlopen", side_effect=http_error(404)): + self.assertEqual( + release_manifest.registry_version_state("https://registry.invalid"), + "absent", + ) + with patch("urllib.request.urlopen", side_effect=http_error(503)): + with self.assertRaisesRegex(SystemExit, "indeterminate"): + release_manifest.registry_version_state("https://registry.invalid") + with patch( + "urllib.request.urlopen", + side_effect=urllib.error.URLError("network unreachable"), + ): + with self.assertRaisesRegex(SystemExit, "registry lookup failed"): + release_manifest.registry_version_state("https://registry.invalid") + + +class ReleaseScriptTests(unittest.TestCase): + def test_release_gate_has_valid_bash_syntax(self) -> None: + result = subprocess.run( + ["bash", "-n", str(SCRIPTS / "release_gate.sh")], + text=True, + capture_output=True, + check=False, + ) + self.assertEqual(result.returncode, 0, result.stderr) + + def test_release_gate_accepts_main_with_post_cut_develop_drift(self) -> None: + """A release stays valid when new work lands on develop after the RC cut.""" + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + remote = root / "remote.git" + repo = root / "repo" + self._git(root, "init", "--bare", str(remote)) + self._git(root, "init", str(repo)) + self._git(repo, "config", "user.email", "tests@example.invalid") + self._git(repo, "config", "user.name", "Publish Kit Tests") + self._git(repo, "remote", "add", "origin", str(remote)) + (repo / ".github" / "scripts").mkdir(parents=True) + (repo / ".github" / "scripts" / "release_artifacts.py").write_text( + "raise SystemExit(0)\n", encoding="utf-8" + ) + (repo / "Cargo.toml").write_text("[workspace]\n", encoding="utf-8") + (repo / "release").mkdir() + (repo / "release" / "publish-artifacts.toml").write_text("", encoding="utf-8") + (repo / "README.md").write_text("base\n", encoding="utf-8") + self._git(repo, "add", ".") + self._git(repo, "commit", "-m", "base") + self._git(repo, "branch", "-M", "main") + self._git(repo, "push", "-u", "origin", "main") + self._git(repo, "checkout", "-b", "develop") + (repo / "README.md").write_text("candidate\n", encoding="utf-8") + self._git(repo, "commit", "-am", "candidate") + self._git(repo, "push", "-u", "origin", "develop") + self._git(repo, "tag", "-a", "release-candidate-v1.2.3", "-m", "candidate") + self._git(repo, "push", "origin", "release-candidate-v1.2.3") + self._git(repo, "checkout", "main") + self._git(repo, "merge", "--ff-only", "develop") + (repo / "CHANGELOG.md").write_text("release metadata\n", encoding="utf-8") + self._git(repo, "add", "CHANGELOG.md") + self._git(repo, "commit", "-m", "release metadata") + self._git(repo, "push", "origin", "main") + self._git(repo, "checkout", "develop") + (repo / "post-cut.rs").write_text("// new develop work\n", encoding="utf-8") + self._git(repo, "add", "post-cut.rs") + self._git(repo, "commit", "-m", "post-cut develop work") + self._git(repo, "push", "origin", "develop") + self._git(repo, "checkout", "main") + release_sha = subprocess.run( + ["git", "rev-parse", "origin/main"], + cwd=repo, + text=True, + capture_output=True, + check=True, + ).stdout.strip() + gate_output = root / "github-output" + + result = subprocess.run( + [ + "bash", + str(SCRIPTS / "release_gate.sh"), + "final", + "origin/main", + "release-candidate-v1.2.3", + "1.2.3", + "release/publish-artifacts.toml", + "Cargo.toml", + ], + cwd=repo, + env={**os.environ, "GITHUB_OUTPUT": str(gate_output)}, + text=True, + capture_output=True, + check=False, + ) + emitted_output = gate_output.read_text(encoding="utf-8") + + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("PASS - release gate checks satisfied", result.stdout) + self.assertEqual(emitted_output, f"release_sha={release_sha}\n") + + def test_release_gate_rejects_candidate_outside_release_history(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + remote = root / "remote.git" + repo = root / "repo" + self._git(root, "init", "--bare", str(remote)) + self._git(root, "init", str(repo)) + self._git(repo, "config", "user.email", "tests@example.invalid") + self._git(repo, "config", "user.name", "Publish Kit Tests") + self._git(repo, "remote", "add", "origin", str(remote)) + (repo / ".github" / "scripts").mkdir(parents=True) + (repo / ".github" / "scripts" / "release_artifacts.py").write_text( + "raise SystemExit(0)\n", encoding="utf-8" + ) + (repo / "Cargo.toml").write_text("[workspace]\n", encoding="utf-8") + (repo / "release").mkdir() + (repo / "release" / "publish-artifacts.toml").write_text("", encoding="utf-8") + (repo / "README.md").write_text("main\n", encoding="utf-8") + self._git(repo, "add", ".") + self._git(repo, "commit", "-m", "main") + self._git(repo, "branch", "-M", "main") + self._git(repo, "push", "-u", "origin", "main") + self._git(repo, "checkout", "--orphan", "unrelated") + self._git(repo, "rm", "-rf", ".") + (repo / "unrelated.txt").write_text("candidate\n", encoding="utf-8") + self._git(repo, "add", "unrelated.txt") + self._git(repo, "commit", "-m", "unrelated candidate") + self._git(repo, "tag", "-a", "release-candidate-v1.2.3", "-m", "candidate") + self._git(repo, "push", "origin", "release-candidate-v1.2.3") + self._git(repo, "checkout", "main") + + result = subprocess.run( + [ + "bash", + str(SCRIPTS / "release_gate.sh"), + "final", + "origin/main", + "release-candidate-v1.2.3", + "1.2.3", + "release/publish-artifacts.toml", + "Cargo.toml", + ], + cwd=repo, + text=True, + capture_output=True, + check=False, + ) + + self.assertNotEqual(result.returncode, 0) + self.assertIn("is not an ancestor of origin/main", result.stderr) + + @staticmethod + def _git(cwd: Path, *args: str) -> None: + result = subprocess.run( + ["git", *args], + cwd=cwd, + text=True, + capture_output=True, + check=False, + ) + if result.returncode: + raise AssertionError(f"git {' '.join(args)} failed: {result.stderr}") + + def test_release_artifacts_cli_exposes_read_only_inquiry(self) -> None: + result = subprocess.run( + [sys.executable, str(SCRIPTS / "release_artifacts.py"), "--help"], + text=True, + capture_output=True, + check=False, + ) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("public-registry-inquiry-plan", result.stdout) + self.assertIn("preflight-secret-plan", result.stdout) + self.assertIn("registry-status", result.stdout) + + def test_bootstrap_enforces_the_exact_documented_renderer_version(self) -> None: + script = SCRIPTS / "bootstrap_sc_compose.py" + text = script.read_text(encoding="utf-8") + probe = text[text.index("def installed_version"):text.index("def require_pinned_version")] + self.assertEqual(BOOTSTRAP.SC_COMPOSE_VERSION, "1.5.0") + self.assertIn('"venv"', text) + self.assertIn('f"sc-compose=={SC_COMPOSE_VERSION}"', text) + self.assertIn("from importlib.metadata import version", probe) + self.assertNotIn("import sc_compose", probe) + self.assertIn("if existing != SC_COMPOSE_VERSION", text) + self.assertIn("install_pinned_wheel(python)", text) + self.assertIn("require_pinned_version(existing)", text) + self.assertIn("managed environment has incompatible sc-compose wheel", text) + self.assertIn("--write-cli", text) + self.assertIn("write_cli_wrapper", text) + + def test_bootstrap_rejects_every_non_pinned_wheel(self) -> None: + with self.assertRaisesRegex( + SystemExit, + r"found '1\.4\.1'; required exactly 1\.5\.0", + ): + BOOTSTRAP.require_pinned_version("1.4.1") + with self.assertRaisesRegex( + SystemExit, + r"found '1\.5\.1'; required exactly 1\.5\.0", + ): + BOOTSTRAP.require_pinned_version("1.5.1") + + def test_bootstrap_accepts_only_the_pinned_wheel(self) -> None: + BOOTSTRAP.require_pinned_version("1.5.0") + + def test_bootstrap_replaces_any_existing_non_pinned_wheel(self) -> None: + python = Path("/tmp/sc-compose-python") + with ( + patch.object(BOOTSTRAP, "installed_version", side_effect=["1.4.1", "1.5.0"]), + patch.object(BOOTSTRAP, "install_pinned_wheel") as install, + ): + BOOTSTRAP.provision_pinned_wheel(python) + install.assert_called_once_with(python) + + def test_bootstrap_does_not_reinstall_the_exact_pinned_wheel(self) -> None: + python = Path("/tmp/sc-compose-python") + with ( + patch.object(BOOTSTRAP, "installed_version", return_value="1.5.0"), + patch.object(BOOTSTRAP, "install_pinned_wheel") as install, + ): + BOOTSTRAP.provision_pinned_wheel(python) + install.assert_not_called() + + def test_write_cli_wrapper_emits_render_compatible_script(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + venv = Path(temporary) + python = venv / "bin" / "python" + wrapper = BOOTSTRAP.write_cli_wrapper(venv, python) + text = wrapper.read_text(encoding="utf-8") + self.assertEqual(wrapper, venv / "bin" / "renderer") + self.assertTrue(wrapper.stat().st_mode & 0o111) + self.assertIn("sc_compose.ComposeMode.file", text) + self.assertIn("--var-file", text) + self.assertIn('choices=["render"]', text) + + def test_runtime_renderer_paths_use_the_bootstrapped_exact_pin(self) -> None: + """Guard every package Python-renderer path against independent pins.""" + repository = PACKAGE_ROOT.parents[1] + bootstrap = (SCRIPTS / "bootstrap_sc_compose.py").read_text(encoding="utf-8") + ci = (repository / ".github" / "workflows" / "ci.yml").read_text(encoding="utf-8") + root_readme = (repository / "README.md").read_text(encoding="utf-8") + package_readme = (PACKAGE_ROOT / "README.md").read_text(encoding="utf-8") + + self.assertEqual(bootstrap.count('SC_COMPOSE_VERSION = "'), 1) + self.assertIn('SC_COMPOSE_VERSION = "1.5.0"', bootstrap) + self.assertIn("bootstrap_sc_compose.py", ci) + self.assertNotRegex(ci, r"sc-compose-[0-9]") + self.assertIn('"$SC_COMPOSE_PYTHON"', ci) + self.assertIn("bootstrap_sc_compose.py", root_readme) + self.assertNotRegex(root_readme, r"sc-publish-[0-9]") + self.assertIn("exact pinned sc-compose 1.5.0 renderer wheel", package_readme) + + for path in repository.rglob("*"): + if not path.is_file() or ".git" in path.parts or "tests" in path.parts: + continue + text = path.read_text(encoding="utf-8", errors="ignore") + for found in re.findall(r"sc-compose==([0-9][0-9.]*)", text): + self.assertEqual(found, BOOTSTRAP.SC_COMPOSE_VERSION, path) + + def test_publisher_profiles_use_the_shared_cli_renderer_contract(self) -> None: + contract = ( + PACKAGE_ROOT + / ".claude" + / "skills" + / "publishing" + / "ref" + / "renderer-contract.md" + ).read_text(encoding="utf-8") + self.assertIn("`sc-compose` CLI", contract) + self.assertIn("SC_COMPOSE_VERSION", contract) + self.assertIn("interpreter printed by `bootstrap_sc_compose.py`", contract) + + for relative in ( + ".claude/agents/publisher.md", + ".claude/agents/publisher-channel-protocol.md", + ".cursor/agents/publisher.md", + ): + text = (PACKAGE_ROOT / relative).read_text(encoding="utf-8") + self.assertIn("renderer-contract.md", text) + self.assertNotIn("import sc_compose", text) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/scripts/tests/test_release_artifacts.py b/.github/scripts/tests/test_release_artifacts.py new file mode 100644 index 00000000..7bf116c4 --- /dev/null +++ b/.github/scripts/tests/test_release_artifacts.py @@ -0,0 +1,3339 @@ +from __future__ import annotations + +import io +import json +import os +import subprocess +import sys +import tarfile +import tomllib +import xml.etree.ElementTree as ET +import zipfile +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from threading import Thread + +import pytest + + +def write_repo_fixture( + tmp_path: Path, + *, + manifest_wheels: list[str], + include_crates: bool = True, + include_python: bool = True, + python_build_system: str = "maturin", +) -> tuple[Path, Path]: + workspace = tmp_path / "Cargo.toml" + workspace.write_text( + "\n".join( + [ + "[workspace]", + 'members = ["crates/sc-composer", "crates/sc-compose"]', + "", + "[workspace.package]", + 'version = "1.1.0"', + "", + ] + ), + encoding="utf-8", + ) + + for crate_name in ("sc-composer", "sc-compose"): + crate_dir = tmp_path / "crates" / crate_name + crate_dir.mkdir(parents=True) + (crate_dir / "Cargo.toml").write_text( + "\n".join( + [ + "[package]", + f'name = "{crate_name}"', + 'version = "1.1.0"', + "", + ] + ), + encoding="utf-8", + ) + + bindings_dir = tmp_path / "bindings" / "python" / "python" / "sc_compose" + bindings_dir.mkdir(parents=True) + (bindings_dir / "__init__.py").write_text("", encoding="utf-8") + (tmp_path / "bindings" / "python" / "pyproject.toml").write_text( + "\n".join( + [ + "[project]", + 'name = "sc-compose"', + 'version = "1.1.0"', + "", + ] + ), + encoding="utf-8", + ) + (tmp_path / "bindings" / "python" / "Cargo.toml").write_text( + "[package]\nname = \"sc-compose-python\"\nversion = \"1.1.0\"\n", + encoding="utf-8", + ) + + manifest = tmp_path / "release" / "publish-artifacts.toml" + manifest.parent.mkdir(parents=True) + (manifest.parent / "publish-channel-contracts.toml").write_text( + (repo_root() / "release" / "publish-channel-contracts.toml.j2").read_text( + encoding="utf-8" + ), + encoding="utf-8", + ) + wheels = ", ".join(f'"{entry}"' for entry in manifest_wheels) + crates_section = [ + "[[crates]]", + 'artifact = "sc-composer"', + 'package = "sc-composer"', + 'cargo_toml = "crates/sc-composer/Cargo.toml"', + "publish_order = 1", + "wait_after_publish_seconds = 0", + "", + "[[crates]]", + 'artifact = "sc-compose"', + 'package = "sc-compose"', + 'cargo_toml = "crates/sc-compose/Cargo.toml"', + "publish_order = 2", + "wait_after_publish_seconds = 0", + "", + ] + build_system_lines = { + "maturin": ['cargo_manifest = "bindings/python/Cargo.toml"'], + "setuptools": ['build_system = "setuptools"'], + "unsupported": ['build_system = "flit"'], + "missing": [], + }[python_build_system] + python_section = [ + "[[python_packages]]", + 'artifact = "sc-compose-python"', + 'package = "sc-compose"', + 'manifest = "bindings/python/pyproject.toml"', + 'module = "sc_compose"', + 'publish = "pypi"', + "", + "[[python_distributions]]", + 'name = "sc-compose"', + 'source = "bindings/python"', + *build_system_lines, + "sdist = true", + f"wheels = [{wheels}]", + "", + ] + manifest.write_text( + "\n".join( + [ + "schema_version = 1", + "", + "[project]", + 'name = "fixture"', + 'archive_prefix = "fixture"', + 'description = "Fixture release"', + 'homepage = "https://example.invalid/fixture"', + 'license = "MIT"', + 'readme_dependency_crate = "sc-composer"', + 'renderer_archive_path = "bin/fixture"', + "", + "[[release_targets]]", + 'target = "x86_64-unknown-linux-gnu"', + 'os = "ubuntu-latest"', + 'archive = "tar.gz"', + "", + "[[release_binaries]]", + 'name = "fixture"', + "", + "[[release_binaries]]", + 'name = "fixture-daemon"', + "", + *(crates_section if include_crates else []), + *(python_section if include_python else []), + "[channels.pypi]", + 'workflow = "pypi-publish.yml"', + 'dispatch_inputs = { target = "production" }', + 'test_repository = "testpypi"', + 'production_repository = "pypi"', + "", + "[channels.homebrew]", + 'workflow = "homebrew-publish.yml"', + 'dispatch_inputs = {}', + 'tap_repository = "example/homebrew-tap"', + 'renderer_target = "x86_64-unknown-linux-gnu"', + "", + "[[channels.homebrew.formulas]]", + 'path = "Formula/fixture.rb"', + 'template = "release/homebrew/formula.rb.j2"', + 'class = "Fixture"', + 'binaries = ["fixture"]', + 'test_command = "--help"', + 'test_output = "fixture"', + 'release_track = "stable"', + "", + "[[channels.homebrew.assets]]", + 'key = "linux"', + 'target = "x86_64-unknown-linux-gnu"', + "", + "[channels.winget]", + 'workflow = "winget-publish.yml"', + 'dispatch_inputs = {}', + 'identifier = "example.fixture"', + 'installer_target = "x86_64-unknown-linux-gnu"', + "", + "[channels.scoop]", + 'workflow = "scoop-publish.yml"', + 'dispatch_inputs = {}', + 'bucket_repository = "example/scoop-bucket"', + 'manifest_path = "fixture.json"', + 'manifest_template = "release/scoop/manifest.json.j2"', + 'installer_target = "x86_64-unknown-linux-gnu"', + 'binary = "bin/fixture"', + 'renderer_target = "x86_64-unknown-linux-gnu"', + "", + ] + ), + encoding="utf-8", + ) + + return workspace, manifest + + +def run_validate_manifest( + tmp_path: Path, *, manifest_wheels: list[str], **fixture_kwargs: object +) -> subprocess.CompletedProcess[str]: + workspace, manifest = write_repo_fixture( + tmp_path, + manifest_wheels=manifest_wheels, + **fixture_kwargs, + ) + return subprocess.run( + [ + sys.executable, + str(scripts_root() / "release_artifacts.py"), + "validate-manifest", + "--manifest", + str(manifest), + "--workspace-toml", + str(workspace), + ], + cwd=tmp_path, + text=True, + capture_output=True, + check=False, + ) + + +def repo_root() -> Path: + # Tests are installed at /.github/scripts/tests. Keep this + # relative to the consumer root so the untouched vendored suite works in + # every repository that installs the publish kit. + return next(path for path in Path(__file__).resolve().parents if (path / "install.py").is_file()) + + +def scripts_root() -> Path: + return repo_root() / ".github" / "scripts" + + +def test_release_artifact_cli_stays_below_the_script_line_ceiling() -> None: + cli_lines = (scripts_root() / "release_artifacts.py").read_text( + encoding="utf-8" + ).splitlines() + assert len(cli_lines) <= 1000 + assert (scripts_root() / "release_manifest.py").is_file() + assert (scripts_root() / "release_registry.py").is_file() + + +def release_workflow_text() -> str: + return (repo_root() / ".github" / "workflows" / "release.yml").read_text(encoding="utf-8") + + +def release_archive_packager_python() -> str: + """Extract the Python executed by the release archive-packaging workflow step.""" + workflow = release_workflow_text() + step = workflow.split(" - name: Package manifest-declared release archive\n", 1)[ + 1 + ].split(" - name: Upload artifact\n", 1)[0] + script = step.split(" python3 - <<'PY'\n", 1)[1].split(" PY\n", 1)[0] + lines = script.splitlines() + assert all(not line or line.startswith(" ") for line in lines) + return "\n".join(line[10:] if line else "" for line in lines) + + +def run_release_archive_packager( + tmp_path: Path, *, target_name: str, expected_filename: str +) -> subprocess.CompletedProcess[str]: + scripts_dir = tmp_path / ".github" / "scripts" + scripts_dir.mkdir(parents=True) + (scripts_dir / "release_artifacts.py").write_text( + "import json\n" + "print(json.dumps({\n" + " 'project': {'archive_prefix': 'fixture'},\n" + " 'target': {'archive': 'zip'},\n" + " 'binaries': [{'name': 'fixture'}],\n" + "}))\n", + encoding="utf-8", + ) + release_dir = tmp_path / "target" / target_name / "release" + release_dir.mkdir(parents=True) + (release_dir / expected_filename).write_text("fixture", encoding="utf-8") + output = tmp_path / "github-env" + script = release_archive_packager_python().replace( + 'target_name = "${{ matrix.target }}"', f"target_name = {target_name!r}" + ).replace( + 'version = "${{ needs.gate-and-tag.outputs.release_version }}"', + 'version = "1.5.0"', + ) + result = subprocess.run( + [sys.executable, "-c", script], + cwd=tmp_path, + env={ + **os.environ, + "RELEASE_ARTIFACT_MANIFEST": str(tmp_path / "release" / "manifest.toml"), + "GITHUB_ENV": str(output), + }, + text=True, + capture_output=True, + check=False, + ) + assert output.read_text(encoding="utf-8").startswith("ARCHIVE=fixture_1.5.0_") + return result + + +def pypi_publish_workflow_text() -> str: + return (repo_root() / ".github" / "workflows" / "pypi-publish.yml").read_text(encoding="utf-8") + + +def homebrew_publish_workflow_text() -> str: + return (repo_root() / ".github" / "workflows" / "homebrew-publish.yml").read_text(encoding="utf-8") + + +def winget_publish_workflow_text() -> str: + return (repo_root() / ".github" / "workflows" / "winget-publish.yml").read_text(encoding="utf-8") + + +def scoop_publish_workflow_text() -> str: + return (repo_root() / ".github" / "workflows" / "scoop-publish.yml").read_text(encoding="utf-8") + + +def crates_publish_workflow_text() -> str: + return (repo_root() / ".github" / "workflows" / "crates-publish.yml").read_text(encoding="utf-8") + + +def release_preflight_workflow_text() -> str: + return (repo_root() / ".github" / "workflows" / "release-preflight.yml").read_text(encoding="utf-8") + + +def release_preflight_step_shell(step_id: str, next_step_id: str) -> str: + """Extract one executed shell body from the release-preflight workflow.""" + workflow = release_preflight_workflow_text() + step = workflow.split(f" - id: {step_id}\n", 1)[1].split( + f" - id: {next_step_id}\n", 1 + )[0] + body = step.split(" run: |\n", 1)[1] + lines = body.splitlines() + assert all(not line or line.startswith(" ") for line in lines) + return "\n".join(line[10:] if line else "" for line in lines) + + +def run_release_preflight_registry_step( + tmp_path: Path, + shell: str, + *, + published: bool, + already_published_channels: str, +) -> subprocess.CompletedProcess[str]: + """Execute a workflow registry step with deterministic registry stand-ins.""" + scripts_dir = tmp_path / ".github" / "scripts" + scripts_dir.mkdir(parents=True, exist_ok=True) + (scripts_dir / "release_artifacts.py").write_text( + "import json\n" + "import os\n" + "import sys\n" + "command = sys.argv[1]\n" + "if command == 'check-version-unpublished':\n" + " preserved = set(filter(None, sys.argv[sys.argv.index('--already-published-channels') + 1].split(',')))\n" + " if os.environ['SIMULATE_PUBLISHED'] == 'true':\n" + " if 'crates_io' not in preserved:\n" + " raise SystemExit('release version already published for: fixture')\n" + " print('ok: crates_io is preserved from a prior release run; version already published for: fixture')\n" + " else:\n" + " print('ok: no publishable artifacts found at version 1.5.0')\n" + "elif command == 'public-registry-check-plan':\n" + " print(json.dumps({'checks': [{\n" + " 'channel': 'crates_io',\n" + " 'agent': 'crates-io-publisher',\n" + " 'registry': 'crates.io',\n" + " 'name': 'fixture',\n" + " 'normalized_name': 'fixture',\n" + " 'expected_version': '1.5.0',\n" + " 'project_lookup_url': 'https://registry.invalid/project',\n" + " 'version_lookup_url': 'https://registry.invalid/version',\n" + " 'version_policy': 'must_be_absent',\n" + " }]}))\n" + "elif command == 'registry-status':\n" + " url = sys.argv[sys.argv.index('--url') + 1]\n" + " if os.environ['SIMULATE_PUBLISHED'] == 'true' or not url.endswith('/version'):\n" + " print('published')\n" + " else:\n" + " print('absent')\n" + "else:\n" + " raise SystemExit(f'unexpected command: {command}')\n", + encoding="utf-8", + ) + return subprocess.run( + ["bash", "-c", shell.replace("'${{ steps.meta.outputs.release_version }}'", "'1.5.0'")], + cwd=tmp_path, + env={ + **os.environ, + "ALREADY_PUBLISHED_CHANNELS": already_published_channels, + "RELEASE_ARTIFACT_MANIFEST": str(tmp_path / "release" / "manifest.toml"), + "SIMULATE_PUBLISHED": str(published).lower(), + }, + text=True, + capture_output=True, + check=False, + ) + + +@pytest.fixture +def published_registry_url() -> str: + """Serve deterministic published-version responses for native CLI checks.""" + + class PublishedVersionHandler(BaseHTTPRequestHandler): + def do_GET(self) -> None: # noqa: N802 - stdlib handler API + self.send_response(200) + self.end_headers() + + def log_message(self, format: str, *args: object) -> None: + del format, args + + server = ThreadingHTTPServer(("127.0.0.1", 0), PublishedVersionHandler) + thread = Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + host, port = server.server_address + yield f"http://{host}:{port}" + finally: + server.shutdown() + thread.join() + server.server_close() + + +def configure_fixture_crates_registry(manifest: Path, registry_url: str) -> None: + """Point a fixture's crates.io contract at the deterministic local server.""" + contracts = manifest.with_name("publish-channel-contracts.toml") + contracts.write_text( + contracts.read_text(encoding="utf-8").replace("https://crates.io", registry_url), + encoding="utf-8", + ) + + +def run_release_gate_readiness( + tmp_path: Path, + *, + manifest: Path, + workspace: Path, + already_published_channels: str, + mode: str = "readiness", + release_ref: str = "HEAD", +) -> subprocess.CompletedProcess[str]: + """Exercise a release-gate mode with real scripts and deterministic Git metadata.""" + scripts_dir = tmp_path / ".github" / "scripts" + scripts_dir.mkdir(parents=True, exist_ok=True) + for script_name in ( + "release_artifacts.py", + "release_manifest.py", + "release_registry.py", + "release_gate.sh", + ): + (scripts_dir / script_name).write_text( + (scripts_root() / script_name).read_text(encoding="utf-8"), encoding="utf-8" + ) + + bin_dir = tmp_path / "bin" + bin_dir.mkdir(exist_ok=True) + git = bin_dir / "git" + git.write_text( + "#!/usr/bin/env bash\n" + "case \"$1\" in\n" + " fetch|merge-base) exit 0 ;;\n" + " rev-parse) printf '%s\\n' deadbeef ;;\n" + " *) exit 1 ;;\n" + "esac\n", + encoding="utf-8", + ) + git.chmod(0o755) + + return subprocess.run( + [ + "bash", + str(scripts_dir / "release_gate.sh"), + mode, + release_ref, + "release-candidate-v1.1.0", + "1.1.0", + str(manifest), + str(workspace), + already_published_channels, + ], + cwd=tmp_path, + env={**os.environ, "PATH": f"{bin_dir}:{os.environ['PATH']}"}, + text=True, + capture_output=True, + check=False, + ) + + +def release_tag_step_shell() -> str: + """Extract the executed shell body that creates or safely reuses a release tag.""" + workflow = release_workflow_text() + step = workflow.split(" - name: Ensure tag is correct or create it\n", 1)[1].split( + "\n build:\n", 1 + )[0] + body = step.split(" run: |\n", 1)[1] + lines = body.splitlines() + assert all(not line or line.startswith(" ") for line in lines) + return "\n".join(line[10:] if line else "" for line in lines) + + +def run_release_tag_step( + tmp_path: Path, + *, + tag_is_main_ancestor: bool, + candidate_is_tag_ancestor: bool, + tag_exists: bool = True, + target: str = "production", +) -> subprocess.CompletedProcess[str]: + """Run tag reuse against deterministic ancestry responses from Git.""" + bin_dir = tmp_path / "bin" + bin_dir.mkdir(parents=True) + git = bin_dir / "git" + git.write_text( + "#!/usr/bin/env bash\n" + "set -euo pipefail\n" + "case \"$1\" in\n" + " fetch) exit 0 ;;\n" + f" ls-remote) exit {0 if tag_exists else 1} ;;\n" + " rev-parse)\n" + " if [[ \"${2:-}\" == \"--verify\" && \"${3:-}\" == \"main-sha^{commit}\" ]]; then\n" + " printf '%s\\n' main-sha\n" + " exit 0\n" + " fi\n" + " case \"$2\" in\n" + " origin/main) printf '%s\\n' main-sha ;;\n" + " refs/tags/v1.5.0\u005e{commit}) printf '%s\\n' release-sha ;;\n" + " *) exit 1 ;;\n" + " esac\n" + " ;;\n" + " merge-base)\n" + " if [[ \"$2\" == \"--is-ancestor\" && \"$3\" == \"release-sha\" && \"$4\" == \"origin/main\" ]]; then\n" + f" exit {0 if tag_is_main_ancestor else 1}\n" + " fi\n" + " if [[ \"$2\" == \"--is-ancestor\" && \"$3\" == \"release-candidate-v1.5.0\" && \"$4\" == \"release-sha\" ]]; then\n" + f" exit {0 if candidate_is_tag_ancestor else 1}\n" + " fi\n" + " exit 1\n" + " ;;\n" + " tag|push) exit 0 ;;\n" + " *) exit 1 ;;\n" + "esac\n", + encoding="utf-8", + ) + git.chmod(0o755) + output = tmp_path / "github-output" + shell = ( + release_tag_step_shell() + .replace("'${{ steps.meta.outputs.release_tag }}'", "'v1.5.0'") + .replace("'${{ steps.meta.outputs.release_target }}'", repr(target)) + .replace("'${{ steps.release_gate.outputs.release_sha }}'", "'main-sha'") + ) + return subprocess.run( + ["bash", "-c", shell], + cwd=tmp_path, + env={ + **os.environ, + "GITHUB_OUTPUT": str(output), + "PATH": f"{bin_dir}:{os.environ['PATH']}", + }, + text=True, + capture_output=True, + check=False, + ) + + +def git_fixture_command(repository: Path, *arguments: str) -> str: + """Run Git in a real fixture repository and return its stdout.""" + result = subprocess.run( + ["git", *arguments], + cwd=repository, + text=True, + capture_output=True, + check=False, + ) + assert result.returncode == 0, result.stderr + return result.stdout.strip() + + +def commit_git_fixture(repository: Path, message: str) -> str: + """Create one durable commit in a real Git fixture repository.""" + state = repository / "state.txt" + previous = state.read_text(encoding="utf-8") if state.exists() else "" + state.write_text(f"{previous}{message}\n", encoding="utf-8") + git_fixture_command(repository, "add", "state.txt") + git_fixture_command(repository, "commit", "-m", message) + return git_fixture_command(repository, "rev-parse", "HEAD") + + +def write_real_release_tag_fixture(tmp_path: Path, scenario: str) -> Path: + """Create remote-backed release ancestry for tag reuse acceptance tests.""" + tmp_path.mkdir() + remote = tmp_path / "origin.git" + repository = tmp_path / "repository" + subprocess.run(["git", "init", "--bare", str(remote)], check=True, capture_output=True) + subprocess.run(["git", "init", str(repository)], check=True, capture_output=True) + git_fixture_command(repository, "config", "user.name", "Release Test") + git_fixture_command(repository, "config", "user.email", "release-test@example.invalid") + git_fixture_command(repository, "checkout", "-b", "main") + + initial = commit_git_fixture(repository, "initial") + git_fixture_command(repository, "remote", "add", "origin", str(remote)) + git_fixture_command(repository, "push", "--set-upstream", "origin", "main") + + commit_git_fixture(repository, "candidate") + git_fixture_command(repository, "tag", "release-candidate-v1.5.0") + if scenario == "accepted": + commit_git_fixture(repository, "release") + git_fixture_command(repository, "tag", "v1.5.0") + commit_git_fixture(repository, "recovery") + elif scenario == "diverged": + git_fixture_command(repository, "checkout", "-b", "diverged", initial) + commit_git_fixture(repository, "diverged-release") + git_fixture_command(repository, "tag", "v1.5.0") + git_fixture_command(repository, "checkout", "main") + commit_git_fixture(repository, "main-after-candidate") + elif scenario == "wrong-candidate": + git_fixture_command(repository, "tag", "v1.5.0", initial) + commit_git_fixture(repository, "main-after-candidate") + else: + raise AssertionError(f"unknown real Git fixture scenario: {scenario}") + + git_fixture_command(repository, "push", "origin", "main", "--tags") + return repository + + +def run_release_tag_step_in_git_fixture(repository: Path) -> subprocess.CompletedProcess[str]: + """Run the exact tag-reuse workflow shell against a real remote-backed repository.""" + shell = ( + release_tag_step_shell() + .replace("'${{ steps.meta.outputs.release_tag }}'", "'v1.5.0'") + .replace("'${{ steps.meta.outputs.release_target }}'", "'production'") + .replace( + "'${{ steps.release_gate.outputs.release_sha }}'", + repr(git_fixture_command(repository, "rev-parse", "origin/main")), + ) + ) + return subprocess.run( + ["bash", "-c", shell], + cwd=repository, + env={**os.environ, "GITHUB_OUTPUT": str(repository / "github-output")}, + text=True, + capture_output=True, + check=False, + ) + + +def release_preflight_channel_results_shell() -> str: + """Extract the executed shell body for the preflight channel-results step.""" + workflow = release_preflight_workflow_text() + step = workflow.split(" - id: channel_results\n", 1)[1].split( + " - name: Deny release after complete preflight summary\n", 1 + )[0] + body = step.split(" run: |\n", 1)[1] + lines = body.splitlines() + assert all(not line or line.startswith(" ") for line in lines) + return "\n".join(line[10:] if line else "" for line in lines) + + +def run_release_preflight_channel_results_shell( + shell: str, *, manifest: Path, output: Path +) -> subprocess.CompletedProcess[str]: + environment = { + **os.environ, + "OWNERSHIP": "success", + "RELEASE_METADATA": "success", + "RELEASE_TAG": "v1.5.0", + "REPOSITORY_SECRETS": "success", + "REPOSITORY_SECRET_CHANNELS": '{"crates_io":"success","homebrew":"success","winget":"success","scoop":"success"}', + "ENVIRONMENT_SECRETS": "success", + "CREDENTIAL_LIVENESS": "success", + "CREDENTIAL_LIVENESS_CHANNELS": '{"crates_io":"success","homebrew":"success","winget":"success","scoop":"success"}', + "REGISTRY_STATE": "success", + "GITHUB_RELEASE_PERMISSIONS": "success", + "RELEASE_ARTIFACT_MANIFEST": str(manifest), + "GITHUB_OUTPUT": str(output), + "GITHUB_STEP_SUMMARY": str(output.with_name("summary.md")), + } + return subprocess.run( + ["bash", "-c", shell], + cwd=repo_root(), + env=environment, + text=True, + capture_output=True, + check=False, + ) + + +def published_release_guard_text() -> str: + return ( + repo_root() / ".github" / "actions" / "verify-published-release" / "action.yml" + ).read_text(encoding="utf-8") + + +def release_manifest() -> dict: + return tomllib.loads( + (repo_root() / "release" / "publish-artifacts.toml").read_text(encoding="utf-8") + ) + + +def require_manifest_crates() -> dict: + if not (repo_root() / "release" / "publish-artifacts.toml").is_file(): + pytest.skip("package source has no consumer-specific rendered manifest") + manifest = release_manifest() + if not manifest.get("crates"): + pytest.skip("consumer manifest does not publish Rust crates") + return manifest + + +def require_full_channel_set() -> dict: + manifest = require_manifest_crates() + required = {"pypi", "homebrew", "winget", "scoop"} + channels = manifest.get("channels", {}) + if not all(channels.get(name, {}).get("enabled") is True for name in required): + pytest.skip("consumer does not enable every post-release channel") + return manifest + + +def renderer_binary() -> str | None: + manifest_path = repo_root() / "release" / "publish-artifacts.toml" + if not manifest_path.is_file(): + return None + binaries = tomllib.loads(manifest_path.read_text(encoding="utf-8")).get( + "release_binaries", [] + ) + return binaries[0].get("name") if binaries else None + + +def python_pyproject_text() -> str: + return (repo_root() / "bindings" / "python" / "pyproject.toml").read_text(encoding="utf-8") + + +def python_cargo_toml_text() -> str: + return (repo_root() / "bindings" / "python" / "Cargo.toml").read_text(encoding="utf-8") + + +def test_validate_manifest_accepts_matching_python_release_shape(tmp_path: Path) -> None: + result = run_validate_manifest( + tmp_path, + manifest_wheels=["ubuntu-latest", "macos-latest", "windows-latest"], + ) + + assert result.returncode == 0, result.stderr + assert "manifest validation passed" in result.stdout + + +def run_fixture_command( + tmp_path: Path, *args: str, manifest: Path +) -> subprocess.CompletedProcess[str]: + return subprocess.run( + [ + sys.executable, + str(scripts_root() / "release_artifacts.py"), + *args, + "--manifest", + str(manifest), + ], + cwd=tmp_path, + text=True, + capture_output=True, + check=False, + ) + + +def test_pure_python_manifest_without_crates_loads_and_gates_cargo_legs(tmp_path: Path) -> None: + result = run_validate_manifest( + tmp_path, manifest_wheels=["ubuntu-latest"], include_crates=False + ) + assert result.returncode == 0, result.stderr + assert "manifest validation passed" in result.stdout + + manifest = tmp_path / "release" / "publish-artifacts.toml" + plan_result = run_fixture_command(tmp_path, "build-plan", manifest=manifest) + assert plan_result.returncode == 0, plan_result.stderr + plan = json.loads(plan_result.stdout) + assert plan["has_crates"] is False + assert plan["has_python_wheels"] is True + assert plan["workspace_toml"] == "Cargo.toml" + + publish_plan = run_fixture_command(tmp_path, "list-publish-plan", manifest=manifest) + assert publish_plan.returncode == 0, publish_plan.stderr + assert publish_plan.stdout.strip() == "" + + +def test_rust_only_manifest_emits_empty_python_matrices(tmp_path: Path) -> None: + result = run_validate_manifest( + tmp_path, manifest_wheels=["ubuntu-latest"], include_python=False + ) + assert result.returncode == 0, result.stderr + + manifest = tmp_path / "release" / "publish-artifacts.toml" + wheel_result = run_fixture_command(tmp_path, "python-wheel-matrix", manifest=manifest) + sdist_result = run_fixture_command(tmp_path, "python-sdist-matrix", manifest=manifest) + plan_result = run_fixture_command(tmp_path, "build-plan", manifest=manifest) + + assert wheel_result.returncode == 0, wheel_result.stderr + assert json.loads(wheel_result.stdout) == {"include": []} + assert sdist_result.returncode == 0, sdist_result.stderr + assert json.loads(sdist_result.stdout) == {"include": []} + assert plan_result.returncode == 0, plan_result.stderr + plan = json.loads(plan_result.stdout) + assert plan["has_crates"] is True + assert plan["has_python_wheels"] is False + assert plan["has_python_sdists"] is False + + +def test_python_matrices_select_the_declared_build_system(tmp_path: Path) -> None: + maturin_result = run_validate_manifest(tmp_path, manifest_wheels=["ubuntu-latest"]) + assert maturin_result.returncode == 0, maturin_result.stderr + manifest = tmp_path / "release" / "publish-artifacts.toml" + wheel_result = run_fixture_command(tmp_path, "python-wheel-matrix", manifest=manifest) + assert wheel_result.returncode == 0, wheel_result.stderr + entry = json.loads(wheel_result.stdout)["include"][0] + assert entry["build_system"] == "maturin" + assert entry["cargo_manifest"] == "bindings/python/Cargo.toml" + + setuptools_dir = tmp_path / "setuptools" + setuptools_dir.mkdir() + setuptools_result = run_validate_manifest( + setuptools_dir, manifest_wheels=["ubuntu-latest"], python_build_system="setuptools" + ) + assert setuptools_result.returncode == 0, setuptools_result.stderr + setuptools_manifest = setuptools_dir / "release" / "publish-artifacts.toml" + setuptools_wheels = run_fixture_command( + setuptools_dir, "python-wheel-matrix", manifest=setuptools_manifest + ) + setuptools_sdists = run_fixture_command( + setuptools_dir, "python-sdist-matrix", manifest=setuptools_manifest + ) + assert setuptools_wheels.returncode == 0, setuptools_wheels.stderr + entry = json.loads(setuptools_wheels.stdout)["include"][0] + assert entry["build_system"] == "setuptools" + assert entry["cargo_manifest"] == "" + assert entry["source"] == "bindings/python" + assert setuptools_sdists.returncode == 0, setuptools_sdists.stderr + assert json.loads(setuptools_sdists.stdout)["include"][0]["build_system"] == "setuptools" + + +def test_validate_manifest_rejects_missing_or_unsupported_build_system(tmp_path: Path) -> None: + unsupported_dir = tmp_path / "unsupported" + unsupported_dir.mkdir() + unsupported = run_validate_manifest( + unsupported_dir, manifest_wheels=["ubuntu-latest"], python_build_system="unsupported" + ) + assert unsupported.returncode != 0 + assert "unsupported build_system" in unsupported.stderr + + missing_dir = tmp_path / "missing" + missing_dir.mkdir() + missing = run_validate_manifest( + missing_dir, manifest_wheels=["ubuntu-latest"], python_build_system="missing" + ) + assert missing.returncode != 0 + assert "unsupported build_system" in missing.stderr + + +def test_crates_leg_is_separate_and_independently_retryable() -> None: + release_text = release_workflow_text() + crates_text = crates_publish_workflow_text() + + # The GitHub Release leg must not depend on crates.io publication. + assert "needs: [gate-and-tag, build, build-python-wheels, build-python-sdists]" in release_text + assert "needs: [gate-and-tag, build, publish," not in release_text + + assert "workflow_dispatch:" in crates_text + assert "uses: ./.github/actions/verify-published-release" in crates_text + assert "release_tag: ${{ inputs.tag }}" in crates_text + assert "group: publish-crates-${{ inputs.tag }}" in crates_text + assert "cancel-in-progress: false" in crates_text + assert "environment: crates-io" in crates_text + assert "publish_if_missing" in crates_text + assert "already published; skipping" in crates_text + assert "list-publish-plan" in crates_text + assert "gate-and-tag" not in crates_text + assert "CARGO_REGISTRY_TOKEN" in crates_text + + +@pytest.mark.parametrize( + ("target_name", "expected_filename"), + ( + ("x86_64-pc-windows-gnu", "fixture.exe"), + ("x86_64-pc-windows-msvc", "fixture.exe"), + ("x86_64-unknown-linux-gnu", "fixture"), + ), +) +def test_release_archive_packager_executes_windows_suffix_logic( + tmp_path: Path, target_name: str, expected_filename: str +) -> None: + """Execute the exact workflow Python against Windows GNU, MSVC, and Linux.""" + result = run_release_archive_packager( + tmp_path, target_name=target_name, expected_filename=expected_filename + ) + + assert result.returncode == 0, result.stderr + archive = tmp_path / f"fixture_1.5.0_{target_name}.zip" + with zipfile.ZipFile(archive) as packaged: + assert packaged.namelist() == [ + f"fixture_1.5.0_{target_name}/bin/{expected_filename}" + ] + + +def test_github_release_leg_is_detect_and_skip(tmp_path: Path) -> None: + text = release_workflow_text() + + assert "replace_release_assets:" in text + assert "release-asset-patterns" in text + assert "id: published_release_probe" in text + assert "uses: ./.github/actions/verify-published-release" in text + # The probe fails closed: no continue-on-error swallowing transient API + # failures, and every build/upload leg keys off the confirmed state. + assert "continue-on-error" not in text + assert "probe: 'true'" in text + assert ( + text.count( + "if: ${{ steps.published_release_probe.outputs.release_state != 'complete' || inputs.replace_release_assets == true }}" + ) + == 4 + ) + assert ( + "if: ${{ steps.published_release_probe.outputs.release_state == 'complete' && inputs.replace_release_assets != true }}" + in text + ) + assert "steps.published_release_probe.outcome" not in text + assert "already exists with every expected asset; skipping upload" in text + assert "deliberately replacing assets" in text + assert "'^checksums\\.txt$'" in text + + _, manifest = write_repo_fixture(tmp_path, manifest_wheels=["ubuntu-latest"]) + result = run_fixture_command(tmp_path, "release-asset-patterns", manifest=manifest) + assert result.returncode == 0, result.stderr + assert result.stdout.splitlines() == [ + r"^fixture_.*_x86_64\-unknown\-linux\-gnu\.tar\.gz$" + ] + + +def test_no_single_repo_concerns_leak_into_kit_workflows_actions_or_scripts() -> None: + """Extend the anti-leakage guard to every vendored workflow, action, and script.""" + forbidden = ("sc-compose", "sc_compose", "randlee") + allowlist = { + # Deliberate shared ecosystem pin: setup-sc-lint's default repository. + "actions/setup-sc-lint/action.yml": {"randlee"}, + # The pinned renderer wheel is the sc-compose PyPI package by design. + "scripts/bootstrap_sc_compose.py": {"sc-compose", "sc_compose"}, + # Composite action invokes the shared bootstrapper by path. + "actions/setup-renderer/action.yml": {"sc-compose", "sc_compose"}, + } + kit_workflows = ( + "release.yml", + "release-candidate.yml", + "release-preflight.yml", + "crates-publish.yml", + "pypi-publish.yml", + "homebrew-publish.yml", + "scoop-publish.yml", + "winget-publish.yml", + ) + kit_actions = ( + "extract-published-renderer", + "install-linux-native-deps", + "setup-lint-toolchain", + "setup-python-release-build", + "setup-renderer", + "setup-sc-lint", + "verify-published-release", + ) + kit_scripts = ( + "bootstrap_sc_compose.py", + "release_artifacts.py", + "release_manifest.py", + "release_registry.py", + "release_gate.sh", + ) + github_root = repo_root() / ".github" + files = [ + *(github_root / "workflows" / name for name in kit_workflows), + *(github_root / "actions" / name / "action.yml" for name in kit_actions), + *(github_root / "scripts" / name for name in kit_scripts), + ] + if (repo_root() / ".sc-publish-source-root").is_file(): + # In the kit source the inventory above must be complete, so new files + # cannot dodge the guard. Consumers may add their own workflows. + assert sorted(path.name for path in (github_root / "workflows").glob("*.yml")) == sorted(kit_workflows) + assert sorted(path.parent.name for path in (github_root / "actions").rglob("action.yml")) == sorted(kit_actions) + assert sorted( + path.name + for path in (github_root / "scripts").iterdir() + if path.suffix in (".py", ".sh") + ) == sorted(kit_scripts) + for path in files: + relative = path.relative_to(github_root).as_posix() + if path.name == "action.yml": + relative = f"actions/{path.parent.name}/action.yml" + text = path.read_text(encoding="utf-8") + for needle in forbidden: + if needle in allowlist.get(relative, set()): + continue + assert needle not in text, f"{relative} leaks single-repo concern {needle!r}" + + +def test_hygiene_single_sources_pins_paths_and_publish_time_validations(tmp_path: Path) -> None: + release_text = release_workflow_text() + preflight_text = release_preflight_workflow_text() + crates_text = crates_publish_workflow_text() + homebrew_text = homebrew_publish_workflow_text() + scoop_text = scoop_publish_workflow_text() + python_action_text = ( + repo_root() / ".github" / "actions" / "setup-python-release-build" / "action.yml" + ).read_text(encoding="utf-8") + sc_lint_action_text = ( + repo_root() / ".github" / "actions" / "setup-sc-lint" / "action.yml" + ).read_text(encoding="utf-8") + + # Item 1: the Rust toolchain pin is single-sourced through build-plan. + for text in (release_text, preflight_text, crates_text, python_action_text): + assert "1.94.1" not in text + assert "rust_toolchain" in text + + # Item 2: the sc-lint repository slug is an input with a documented pin. + assert "SC_LINT_REPOSITORY" in sc_lint_action_text + assert 'default: "randlee/sc-lint"' in sc_lint_action_text + assert "https://github.com/randlee" not in sc_lint_action_text + + # Item 5: release.yml reads the manifest path from its env everywhere. + assert release_text.count("release/publish-artifacts.toml") == 1 + + # Item 7: tap/bucket pushes fetch-rebase-retry instead of racing. + for text in (homebrew_text, scoop_text): + assert "git pull --rebase origin" in text + assert "for attempt in 1 2 3 4 5; do" in text + assert "push rejected (attempt" in text + + # Item 8: the pyproject input is required, not layout-inferred. + assert "bindings/python/pyproject.toml" not in python_action_text + + # Item 6: contract-declared GitHub environments are verified by preflight. + assert "Verify contract-declared GitHub environments exist" in preflight_text + assert ".github_environments[]?" in preflight_text + _, manifest = write_repo_fixture(tmp_path, manifest_wheels=["ubuntu-latest"]) + plan_result = run_fixture_command(tmp_path, "preflight-secret-plan", manifest=manifest) + assert plan_result.returncode == 0, plan_result.stderr + assert json.loads(plan_result.stdout)["github_environments"] == [ + "crates-io", + "pypi", + "testpypi", + ] + + +def test_validate_manifest_requires_publish_time_channel_fields(tmp_path: Path) -> None: + pypi_dir = tmp_path / "pypi" + pypi_dir.mkdir() + workspace, manifest = write_repo_fixture(pypi_dir, manifest_wheels=["ubuntu-latest"]) + manifest.write_text( + manifest.read_text(encoding="utf-8").replace( + 'test_repository = "testpypi"', 'test_repository = ""', 1 + ), + encoding="utf-8", + ) + result = run_fixture_command( + pypi_dir, + "validate-manifest", + "--workspace-toml", + str(workspace), + manifest=manifest, + ) + assert result.returncode != 0 + assert "[channels.pypi].test_repository must be a non-empty string" in result.stderr + + winget_dir = tmp_path / "winget" + winget_dir.mkdir() + workspace, manifest = write_repo_fixture(winget_dir, manifest_wheels=["ubuntu-latest"]) + manifest.write_text( + manifest.read_text(encoding="utf-8").replace( + 'identifier = "example.fixture"', 'identifier = ""', 1 + ), + encoding="utf-8", + ) + result = run_fixture_command( + winget_dir, + "validate-manifest", + "--workspace-toml", + str(workspace), + manifest=manifest, + ) + assert result.returncode != 0 + assert "[channels.winget].identifier must be a non-empty string" in result.stderr + + +def test_load_manifest_rejects_unsupported_schema_version(tmp_path: Path) -> None: + _, manifest = write_repo_fixture(tmp_path, manifest_wheels=["ubuntu-latest"]) + manifest.write_text( + manifest.read_text(encoding="utf-8").replace( + "schema_version = 1", "schema_version = 2", 1 + ), + encoding="utf-8", + ) + result = run_fixture_command(tmp_path, "build-plan", manifest=manifest) + assert result.returncode != 0 + assert "unsupported manifest schema_version" in result.stderr + + +def test_winget_leg_probes_before_submitting_and_pins_the_releaser() -> None: + text = winget_publish_workflow_text() + + assert "id: winget_probe" in text + assert "repos/microsoft/winget-pkgs/contents/${manifest_path}" in text + assert "search/issues" in text + assert "type:pr in:title" in text + assert "already_published" in text + assert "if: ${{ steps.winget_probe.outputs.already_published != 'true' }}" in text + # Fail closed: only a confirmed 404 may fall through to the PR search, + # and incomplete search results must not be read as "no duplicate". + assert "grep -Eqi 'HTTP 404|Not Found'" in text + assert "is indeterminate (not a confirmed 404); failing closed" in text + assert "incomplete_results" in text + # The third-party releaser must be pinned to an immutable commit SHA. + assert ( + "uses: vedantmgoyal2009/winget-releaser@4ffc7888bffd451b357355dc214d43bb9f23917e # v2" + in text + ) + assert "winget-releaser@v2\n" not in text + + +def test_crates_already_published_detection_uses_exact_version_lookup() -> None: + release_text = release_workflow_text() + crates_text = crates_publish_workflow_text() + script_text = (scripts_root() / "release_artifacts.py").read_text(encoding="utf-8") + registry_script_text = (scripts_root() / "release_registry.py").read_text(encoding="utf-8") + manifest_module_text = (scripts_root() / "release_manifest.py").read_text(encoding="utf-8") + + for text in (release_text, crates_text): + assert "cargo search" not in text + assert "public-registry-inquiry-plan" in text + assert "version_lookup_url" in text + assert "publish-channel-contracts.toml" in text + assert "indeterminate" in text + assert "registry-status --url" in text + assert "--write-out '%{http_code}'" not in text + + assert "cargo search" not in script_text + assert "cmd_check_version_unpublished" in script_text + assert "check_version_publication" in registry_script_text + assert "registry_version_state" in manifest_module_text + assert "must_be_absent" not in release_text # policy lives in the contract + assert "registry lookup failed" in manifest_module_text + + +def test_release_workflows_gate_cargo_and_python_legs_on_the_manifest() -> None: + release_text = release_workflow_text() + preflight_text = release_preflight_workflow_text() + + assert "build-plan" in release_text + assert "needs.release-plan.outputs.has_crates == 'true'" in release_text + assert "needs.release-plan.outputs.has_python_wheels == 'true'" in release_text + assert "needs.release-plan.outputs.has_python_sdists == 'true'" in release_text + assert "Build wheels (maturin)" in release_text + assert "Build wheels (setuptools)" in release_text + assert "matrix.build_system == 'setuptools'" in release_text + assert "python -m build --wheel" in release_text + assert "python -m build --sdist" in release_text + assert "steps.build_plan.outputs.workspace_toml" in release_text + + assert "build-plan" in preflight_text + assert preflight_text.count("steps.build_plan.outputs.has_crates == 'true'") >= 5 + assert "steps.build_plan.outputs.workspace_toml" in preflight_text + assert '--workspace-toml Cargo.toml' not in preflight_text + assert 'if [[ "${HAS_CRATES}" == "true" ]]; then' in preflight_text + + +def test_homebrew_workflow_selects_manifest_formula_tracks(tmp_path: Path) -> None: + _, manifest = write_repo_fixture(tmp_path, manifest_wheels=["ubuntu-latest"]) + formulas = """ +[[channels.homebrew.formulas]] +path = "Formula/fixture-alt.rb" +template = "release/homebrew/alternate.rb.j2" +class = "FixtureAlt" +binaries = ["fixture", "fixture-daemon"] +test_binary = "fixture-daemon" +test_command = "--version" +test_output = "fixture-alt" +release_track = "stable" + +[[channels.homebrew.formulas]] +path = "Formula/fixture-preview.rb" +template = "release/homebrew/preview.rb.j2" +class = "FixturePreview" +binaries = ["fixture"] +test_command = "--version" +test_output = "fixture-preview" +release_track = "prerelease" + +""" + manifest.write_text( + manifest.read_text(encoding="utf-8").replace( + "[[channels.homebrew.assets]]", formulas + "[[channels.homebrew.assets]]", 1 + ), + encoding="utf-8", + ) + + def channel_config(tag: str) -> dict: + result = subprocess.run( + [ + sys.executable, + str(scripts_root() / "release_artifacts.py"), + "channel-config", + "--manifest", + str(manifest), + "--channel", + "homebrew", + "--tag", + tag, + ], + cwd=tmp_path, + text=True, + capture_output=True, + check=False, + ) + assert result.returncode == 0, result.stderr + return json.loads(result.stdout) + + stable = channel_config("v1.2.3") + prerelease = channel_config("v1.2.4-rc.1") + assert [formula["path"] for formula in stable["channel"]["formulas"]] == [ + "Formula/fixture.rb", + "Formula/fixture-alt.rb", + ] + assert [formula["path"] for formula in prerelease["channel"]["formulas"]] == [ + "Formula/fixture-preview.rb" + ] + assert stable["channel"]["formulas"][1]["binaries"] == ["fixture", "fixture-daemon"] + assert stable["channel"]["formulas"][1]["test_binary"] == "fixture-daemon" + + workflow = homebrew_publish_workflow_text() + assert '--tag "${{ inputs.tag }}"' in workflow + assert 'channel["formulas"]' in workflow + assert 'formula["path"]' in workflow + assert 'formula["template"]' in workflow + assert "Formula/fixture" not in workflow + assert "FixturePreview" not in workflow + assert "sc-compose" not in workflow + assert "randlee" not in workflow + + +def test_homebrew_asset_writer_and_formula_renderer_share_keyed_object_shape() -> None: + """The formula renderer must consume the JSON object emitted by the asset writer.""" + workflow = homebrew_publish_workflow_text() + + assert 'Path("homebrew-release-assets.json").write_text(json.dumps(assets)' in workflow + assert 'assets = json.loads(Path("homebrew-release-assets.json").read_text())' in workflow + assert 'assets = {asset["key"]: asset for asset in json.loads(' not in workflow + + +def test_homebrew_legacy_binary_normalizes_to_a_single_binary_list(tmp_path: Path) -> None: + _, manifest = write_repo_fixture(tmp_path, manifest_wheels=["ubuntu-latest"]) + manifest.write_text( + manifest.read_text(encoding="utf-8").replace( + 'binaries = ["fixture"]', 'binary = "fixture"', 1 + ), + encoding="utf-8", + ) + result = subprocess.run( + [ + sys.executable, + str(scripts_root() / "release_artifacts.py"), + "channel-config", + "--manifest", + str(manifest), + "--channel", + "homebrew", + "--tag", + "v1.2.3", + ], + cwd=tmp_path, + text=True, + capture_output=True, + check=False, + ) + + assert result.returncode == 0, result.stderr + formula = json.loads(result.stdout)["channel"]["formulas"][0] + assert formula["binaries"] == ["fixture"] + assert formula["test_binary"] == "fixture" + + +def test_validate_manifest_rejects_unknown_homebrew_formula_binary(tmp_path: Path) -> None: + workspace, manifest = write_repo_fixture(tmp_path, manifest_wheels=["ubuntu-latest"]) + manifest.write_text( + manifest.read_text(encoding="utf-8").replace( + 'binaries = ["fixture"]', 'binaries = ["not-a-release-binary"]', 1 + ), + encoding="utf-8", + ) + result = subprocess.run( + [ + sys.executable, + str(scripts_root() / "release_artifacts.py"), + "validate-manifest", + "--manifest", + str(manifest), + "--workspace-toml", + str(workspace), + ], + cwd=tmp_path, + text=True, + capture_output=True, + check=False, + ) + + assert result.returncode != 0 + assert "references undeclared release binary(s)" in result.stderr + + +def test_validate_manifest_rejects_unknown_channel_target(tmp_path: Path) -> None: + workspace, manifest = write_repo_fixture(tmp_path, manifest_wheels=["ubuntu-latest"]) + manifest.write_text( + manifest.read_text(encoding="utf-8").replace( + 'installer_target = "x86_64-unknown-linux-gnu"', + 'installer_target = "unknown-target"', + 1, + ), + encoding="utf-8", + ) + result = subprocess.run( + [ + sys.executable, + str(scripts_root() / "release_artifacts.py"), + "validate-manifest", + "--manifest", + str(manifest), + "--workspace-toml", + str(workspace), + ], + cwd=tmp_path, + text=True, + capture_output=True, + check=False, + ) + assert result.returncode != 0 + assert "references unknown release target" in result.stderr + + +def test_validate_manifest_requires_manifest_driven_scoop_channel_inputs(tmp_path: Path) -> None: + workspace, manifest = write_repo_fixture(tmp_path, manifest_wheels=["ubuntu-latest"]) + manifest.write_text( + manifest.read_text(encoding="utf-8").replace( + 'manifest_template = "release/scoop/manifest.json.j2"\n', "", 1 + ), + encoding="utf-8", + ) + result = subprocess.run( + [ + sys.executable, + str(scripts_root() / "release_artifacts.py"), + "validate-manifest", + "--manifest", + str(manifest), + "--workspace-toml", + str(workspace), + ], + cwd=tmp_path, + text=True, + capture_output=True, + check=False, + ) + + assert result.returncode != 0 + assert "[channels.scoop] missing required keys: manifest_template" in result.stderr + + +def test_validate_manifest_rejects_unknown_renderer_target(tmp_path: Path) -> None: + workspace, manifest = write_repo_fixture(tmp_path, manifest_wheels=["ubuntu-latest"]) + manifest.write_text( + manifest.read_text(encoding="utf-8").replace( + 'renderer_target = "x86_64-unknown-linux-gnu"', + 'renderer_target = "unknown-renderer"', + 1, + ), + encoding="utf-8", + ) + result = subprocess.run( + [ + sys.executable, + str(scripts_root() / "release_artifacts.py"), + "validate-manifest", + "--manifest", + str(manifest), + "--workspace-toml", + str(workspace), + ], + cwd=tmp_path, + text=True, + capture_output=True, + check=False, + ) + + assert result.returncode != 0 + assert "renderer_target references unknown release target" in result.stderr + + +def test_validate_manifest_requires_explicit_homebrew_bundle_destination(tmp_path: Path) -> None: + workspace, manifest = write_repo_fixture(tmp_path, manifest_wheels=["ubuntu-latest"]) + manifest.write_text( + manifest.read_text(encoding="utf-8").replace( + "[[release_binaries]]\nname = \"fixture\"", + "[[release_binaries]]\nname = \"fixture\"\nbundled_paths = [{ source = \"examples\", destination = \"share/fixture/examples\" }]", + ), + encoding="utf-8", + ) + result = subprocess.run( + [ + sys.executable, + str(scripts_root() / "release_artifacts.py"), + "validate-manifest", + "--manifest", + str(manifest), + "--workspace-toml", + str(workspace), + ], + cwd=tmp_path, + text=True, + capture_output=True, + check=False, + ) + + assert result.returncode != 0 + assert "homebrew_destination_components" in result.stderr + + +def test_verify_python_release_assets_accepts_manifest_declared_wheels_and_sdist(tmp_path: Path) -> None: + _, manifest = write_repo_fixture(tmp_path, manifest_wheels=["ubuntu-latest", "windows-latest"]) + assets = tmp_path / "assets" + assets.mkdir() + for suffix in ("linux", "windows"): + with zipfile.ZipFile(assets / f"fixture-{suffix}.whl", "w") as wheel: + wheel.writestr("fixture-1.1.0.dist-info/METADATA", "Name: sc-compose\nVersion: 1.1.0\n") + with tarfile.open(assets / "fixture-1.1.0.tar.gz", "w:gz") as sdist: + metadata = b"Name: sc-compose\nVersion: 1.1.0\n" + info = tarfile.TarInfo("fixture-1.1.0/PKG-INFO") + info.size = len(metadata) + sdist.addfile(info, io.BytesIO(metadata)) + + result = subprocess.run( + [ + sys.executable, + str(scripts_root() / "release_artifacts.py"), + "verify-python-release-assets", + "--manifest", + str(manifest), + "--asset-dir", + str(assets), + ], + cwd=tmp_path, + text=True, + capture_output=True, + check=False, + ) + + assert result.returncode == 0, result.stderr + assert "'sc-compose': {'wheel': 2, 'sdist': 1}" in result.stdout + + +def test_release_manifest_publishes_sc_sha_before_its_consumers() -> None: + """Keep manifest-declared crate ordering and fields regression-tested.""" + manifest = require_manifest_crates() + crates = [entry for entry in manifest["crates"] if entry.get("publish", True)] + orders = [entry["publish_order"] for entry in crates] + assert orders == sorted(orders) + assert len(orders) == len(set(orders)) + names = [entry["package"] for entry in crates] + if {"sc-sha", "sc-composer", "sc-compose"}.issubset(names): + positions = {name: names.index(name) for name in names} + assert positions["sc-sha"] < positions["sc-composer"] < positions["sc-compose"] + for entry in crates: + assert entry["artifact"] + assert entry["package"] + assert entry["cargo_toml"].endswith("Cargo.toml") + assert entry["wait_after_publish_seconds"] >= 0 + assert manifest["channels"] + + +def run_manifest_command(*args: str) -> subprocess.CompletedProcess[str]: + return subprocess.run( + [ + sys.executable, + str(scripts_root() / "release_artifacts.py"), + *args, + ], + cwd=repo_root(), + text=True, + capture_output=True, + check=False, + ) + + +def test_manifest_drives_parallel_post_release_dispatch_plan() -> None: + require_full_channel_set() + result = run_manifest_command( + "channel-dispatch-plan", + "--manifest", + "release/publish-artifacts.toml", + "--tag", + "v1.4.2", + ) + + assert result.returncode == 0, result.stderr + channels = json.loads(result.stdout)["channels"] + assert [channel["name"] for channel in channels] == [ + "pypi", + "homebrew", + "winget", + "scoop", + ] + assert channels[0] == { + "name": "pypi", + "agent": "pypi-publisher", + "workflow": "pypi-publish.yml", + "inputs": {"tag": "v1.4.2", "target": "production"}, + "credential_rehearsal": { + "workflow": "pypi-publish.yml", + "inputs": {"tag": "v1.4.2", "target": "testpypi"}, + }, + "preflight": { + "agent": "pypi-publisher", + "repository_secrets": [], + "environment_secrets": [ + {"environment": "pypi", "name": "PYPI_API_TOKEN"}, + {"environment": "testpypi", "name": "TEST_PYPI_API_TOKEN"}, + ], + "liveness_checks": [], + "public_registry_checks": True, + "credential_rehearsal": { + "workflow": "pypi-publish.yml", + "inputs": {"target": "testpypi"}, + }, + }, + } + assert channels[1]["preflight"] == { + "agent": "homebrew-publisher", + "repository_secrets": ["HOMEBREW_TAP_TOKEN"], + "environment_secrets": [], + "liveness_checks": [{"name": "HOMEBREW_TAP_TOKEN", "kind": "github"}], + "public_registry_checks": False, + "credential_rehearsal": None, + } + assert channels[2]["preflight"] == { + "agent": "winget-publisher", + "repository_secrets": ["WINGET_GITHUB_TOKEN"], + "environment_secrets": [], + "liveness_checks": [{"name": "WINGET_GITHUB_TOKEN", "kind": "github"}], + "public_registry_checks": False, + "credential_rehearsal": None, + } + assert channels[3]["preflight"] == { + "agent": "scoop-publisher", + "repository_secrets": ["SCOOP_BUCKET_TOKEN"], + "environment_secrets": [], + "liveness_checks": [{"name": "SCOOP_BUCKET_TOKEN", "kind": "github"}], + "public_registry_checks": False, + "credential_rehearsal": None, + } + + +def test_manifest_drives_non_disclosing_preflight_secret_plan() -> None: + require_full_channel_set() + result = run_manifest_command( + "preflight-secret-plan", + "--manifest", + "release/publish-artifacts.toml", + ) + + assert result.returncode == 0, result.stderr + plan = json.loads(result.stdout) + assert plan["repository_secrets"] == [ + "CARGO_REGISTRY_TOKEN", + "HOMEBREW_TAP_TOKEN", + "WINGET_GITHUB_TOKEN", + "SCOOP_BUCKET_TOKEN", + ] + assert plan["repository_secret_channels"] == [ + {"name": "crates_io", "secrets": ["CARGO_REGISTRY_TOKEN"]}, + {"name": "homebrew", "secrets": ["HOMEBREW_TAP_TOKEN"]}, + {"name": "winget", "secrets": ["WINGET_GITHUB_TOKEN"]}, + {"name": "scoop", "secrets": ["SCOOP_BUCKET_TOKEN"]}, + ] + assert plan["environment_secrets"] == [ + {"environment": "pypi", "name": "PYPI_API_TOKEN"}, + {"environment": "testpypi", "name": "TEST_PYPI_API_TOKEN"}, + ] + assert plan["liveness_channel_checks"] == [ + {"channel": "homebrew", "name": "HOMEBREW_TAP_TOKEN", "kind": "github"}, + {"channel": "winget", "name": "WINGET_GITHUB_TOKEN", "kind": "github"}, + {"channel": "scoop", "name": "SCOOP_BUCKET_TOKEN", "kind": "github"}, + ] + contracts = { + entry["name"]: entry + for entry in [*plan["root_channels"], *plan["post_release_channels"]] + } + assert {name: entry["agent"] for name, entry in contracts.items()} == { + "crates_io": "crates-io-publisher", + "github_release": "github-release-publisher", + "pypi": "pypi-publisher", + "homebrew": "homebrew-publisher", + "winget": "winget-publisher", + "scoop": "scoop-publisher", + } + assert contracts["crates_io"]["public_registry_checks"] is True + assert contracts["crates_io"]["liveness_checks"] == [] + assert contracts["pypi"]["public_registry_checks"] is True + assert contracts["github_release"]["github_actions_permissions"] == ["contents:write"] + assert contracts["pypi"]["credential_rehearsal"] == { + "workflow": "pypi-publish.yml", + "inputs": {"target": "testpypi"}, + } + + +def test_channel_preflight_results_execute_contract_outcome_mapping() -> None: + require_full_channel_set() + passing_outcomes = json.dumps( + { + "ownership": "success", + "release_metadata": "success", + "repository_secrets": "success", + "repository_secret_channels": { + "crates_io": "success", + "homebrew": "success", + "winget": "success", + "scoop": "success", + }, + "environment_secrets": "success", + "credential_liveness": "success", + "credential_liveness_channels": { + "crates_io": "success", + "homebrew": "success", + "winget": "success", + "scoop": "success", + }, + "registry_state": "success", + "github_release_permissions": "success", + } + ) + result = run_manifest_command( + "channel-preflight-results", + "--manifest", + "release/publish-artifacts.toml", + "--outcomes", + passing_outcomes, + "--tag", + "v1.4.2", + ) + + assert result.returncode == 0, result.stderr + channels = {entry["name"]: entry for entry in json.loads(result.stdout)["channels"]} + assert json.loads(result.stdout)["tag"] == "v1.4.2" + assert list(channels) == [ + "crates_io", + "github_release", + "pypi", + "homebrew", + "winget", + "scoop", + ] + assert all(channel["status"] == "passed" for channel in channels.values()) + assert all(channel["tag"] == "v1.4.2" for channel in channels.values()) + assert channels["pypi"]["credential_rehearsal"] == { + "workflow": "pypi-publish.yml", + "inputs": {"target": "testpypi"}, + } + + failed_outcomes = json.dumps( + { + "ownership": "success", + "release_metadata": "success", + "repository_secrets": "failure", + "repository_secret_channels": { + "crates_io": "success", + "homebrew": "success", + "winget": "success", + "scoop": "failure", + }, + "environment_secrets": "success", + "credential_liveness": "success", + "credential_liveness_channels": { + "crates_io": "success", + "homebrew": "success", + "winget": "success", + "scoop": "success", + }, + "registry_state": "success", + "github_release_permissions": "success", + } + ) + failed_result = run_manifest_command( + "channel-preflight-results", + "--manifest", + "release/publish-artifacts.toml", + "--outcomes", + failed_outcomes, + "--tag", + "v1.4.2", + ) + + assert failed_result.returncode == 0, failed_result.stderr + failed_channels = { + entry["name"] + for entry in json.loads(failed_result.stdout)["channels"] + if entry["status"] == "failed" + } + assert failed_channels == {"scoop"} + + legacy_scalar_result = run_manifest_command( + "channel-preflight-results", + "--manifest", + "release/publish-artifacts.toml", + "--outcomes", + json.dumps( + { + "ownership": "success", + "release_metadata": "success", + "repository_secrets": "success", + "environment_secrets": "success", + "credential_liveness": "success", + "registry_state": "success", + "github_release_permissions": "success", + } + ), + "--tag", + "v1.4.2", + ) + assert legacy_scalar_result.returncode == 0, legacy_scalar_result.stderr + assert all( + entry["status"] == "passed" + for entry in json.loads(legacy_scalar_result.stdout)["channels"] + ) + + unauthorized_outcomes = json.dumps( + { + "ownership": "failure", + "release_metadata": "success", + "repository_secrets": "success", + "environment_secrets": "success", + "credential_liveness": "success", + "registry_state": "success", + "github_release_permissions": "success", + } + ) + unauthorized_result = run_manifest_command( + "channel-preflight-results", + "--manifest", + "release/publish-artifacts.toml", + "--outcomes", + unauthorized_outcomes, + "--tag", + "v1.4.2", + ) + + assert unauthorized_result.returncode == 0, unauthorized_result.stderr + assert all( + entry["status"] == "failed" + for entry in json.loads(unauthorized_result.stdout)["channels"] + ) + + invalid_tag_outcomes = json.dumps( + { + "ownership": "success", + "release_metadata": "failure", + "repository_secrets": "success", + "repository_secret_channels": { + "crates_io": "success", + "homebrew": "success", + "winget": "success", + "scoop": "success", + }, + "environment_secrets": "success", + "credential_liveness": "success", + "credential_liveness_channels": { + "crates_io": "success", + "homebrew": "success", + "winget": "success", + "scoop": "success", + }, + "registry_state": "success", + "github_release_permissions": "success", + } + ) + invalid_tag_result = run_manifest_command( + "channel-preflight-results", + "--manifest", + "release/publish-artifacts.toml", + "--outcomes", + invalid_tag_outcomes, + "--tag", + "v1.4.2-preflight-check", + ) + + assert invalid_tag_result.returncode == 0, invalid_tag_result.stderr + for channel in json.loads(invalid_tag_result.stdout)["channels"]: + assert channel["status"] == "failed" + assert { + "kind": "release_authorization", + "requirements": ["normalized release tag"], + "status": "failed", + } in channel["checks"] + + blocked_result = run_manifest_command( + "channel-preflight-results", + "--manifest", + "release/publish-artifacts.toml", + "--outcomes", + "{}", + "--tag", + "", + ) + + assert blocked_result.returncode == 0, blocked_result.stderr + assert { + entry["name"] + for entry in json.loads(blocked_result.stdout)["channels"] + if entry["status"] == "blocked" + } == set(channels) + assert json.loads(blocked_result.stdout)["tag"] is None + + +def test_background_workers_consume_and_gate_their_own_preflight_contracts() -> None: + require_full_channel_set() + plan_result = run_manifest_command( + "preflight-secret-plan", + "--manifest", + "release/publish-artifacts.toml", + ) + assert plan_result.returncode == 0, plan_result.stderr + plan = json.loads(plan_result.stdout) + worker_contracts = { + entry["name"]: entry + for entry in [*plan["root_channels"], *plan["post_release_channels"]] + } + assert set(worker_contracts) == { + "crates_io", + "github_release", + "pypi", + "homebrew", + "winget", + "scoop", + } + + def results_for(outcomes: dict[str, object]) -> dict[str, dict]: + result = run_manifest_command( + "channel-preflight-results", + "--manifest", + "release/publish-artifacts.toml", + "--outcomes", + json.dumps(outcomes), + "--tag", + "v1.4.2", + ) + assert result.returncode == 0, result.stderr + return {entry["name"]: entry for entry in json.loads(result.stdout)["channels"]} + + passed_outcomes = { + "ownership": "success", + "release_metadata": "success", + "repository_secrets": "success", + "repository_secret_channels": { + "crates_io": "success", + "homebrew": "success", + "winget": "success", + "scoop": "success", + }, + "environment_secrets": "success", + "credential_liveness": "success", + "credential_liveness_channels": { + "crates_io": "success", + "homebrew": "success", + "winget": "success", + "scoop": "success", + }, + "registry_state": "success", + "github_release_permissions": "success", + } + passed = results_for(passed_outcomes) + for channel_name, contract in worker_contracts.items(): + assert passed[channel_name]["agent"] == contract["agent"] + assert passed[channel_name]["status"] == "passed" + + pypi_credential_failed = results_for( + {**passed_outcomes, "environment_secrets": "failure"} + ) + assert pypi_credential_failed["pypi"]["status"] == "failed" + assert all( + result["status"] == "passed" + for channel_name, result in pypi_credential_failed.items() + if channel_name != "pypi" + ) + + crates_secret_failed = results_for( + { + **passed_outcomes, + "repository_secret_channels": { + "crates_io": "failure", + "homebrew": "success", + "winget": "success", + "scoop": "success", + }, + "credential_liveness_channels": { + "crates_io": "failure", + "homebrew": "success", + "winget": "success", + "scoop": "success", + }, + } + ) + assert crates_secret_failed["crates_io"]["status"] == "failed" + assert all( + result["status"] == "passed" + for channel_name, result in crates_secret_failed.items() + if channel_name != "crates_io" + ) + + scoop_liveness_failed = results_for( + { + **passed_outcomes, + "repository_secret_channels": { + "crates_io": "success", + "homebrew": "success", + "winget": "success", + "scoop": "success", + }, + "credential_liveness_channels": { + "crates_io": "success", + "homebrew": "success", + "winget": "success", + "scoop": "failure", + }, + } + ) + assert scoop_liveness_failed["scoop"]["status"] == "failed" + assert all( + result["status"] == "passed" + for channel_name, result in scoop_liveness_failed.items() + if channel_name != "scoop" + ) + + github_permission_failed = results_for( + {**passed_outcomes, "github_release_permissions": "failure"} + ) + assert github_permission_failed["github_release"]["status"] == "failed" + assert all( + result["status"] == "passed" + for channel_name, result in github_permission_failed.items() + if channel_name != "github_release" + ) + + +def test_public_registry_check_plan_assigns_named_agents_and_normalizes_python_names() -> None: + manifest = require_manifest_crates() + result = run_manifest_command( + "public-registry-check-plan", + "--manifest", + "release/publish-artifacts.toml", + "--version", + "1.4.2", + ) + + assert result.returncode == 0, result.stderr + checks = json.loads(result.stdout)["checks"] + crates = [entry for entry in checks if entry["channel"] == "crates_io"] + pypi = [entry for entry in checks if entry["channel"] == "pypi"] + assert [entry["name"] for entry in crates] == [ + entry["package"] for entry in manifest["crates"] + ] + assert all(entry["agent"] == "crates-io-publisher" for entry in crates) + assert all(entry["version_policy"] == "must_be_absent" for entry in crates) + assert all("/api/v1/crates/" in entry["project_lookup_url"] for entry in crates) + if manifest.get("python_packages"): + assert {entry["registry"] for entry in pypi} == {"pypi", "testpypi"} + assert all(entry["agent"] == "pypi-publisher" for entry in pypi) + assert all("_" not in entry["normalized_name"] for entry in pypi) + assert any(entry["version_policy"] == "informational" for entry in pypi) + + +def test_public_registry_inquiry_plan_is_contract_derived_and_read_only() -> None: + crates = run_manifest_command( + "public-registry-inquiry-plan", + "--contracts", + "release/publish-channel-contracts.toml.j2", + "--channel", + "crates_io", + "--name", + "atm-serde", + "--version", + "0.1.0", + ) + pypi = run_manifest_command( + "public-registry-inquiry-plan", + "--contracts", + "release/publish-channel-contracts.toml.j2", + "--channel", + "pypi", + "--name", + "ATM_Serde", + ) + + assert crates.returncode == 0, crates.stderr + assert pypi.returncode == 0, pypi.stderr + crate_check = json.loads(crates.stdout)["checks"] + pypi_checks = json.loads(pypi.stdout)["checks"] + assert crate_check == [ + { + "channel": "crates_io", + "agent": "crates-io-publisher", + "registry": "crates.io", + "name": "atm-serde", + "normalized_name": "atm-serde", + "expected_version": "0.1.0", + "project_lookup_url": "https://crates.io/api/v1/crates/atm-serde", + "version_lookup_url": "https://crates.io/api/v1/crates/atm-serde/0.1.0", + "version_policy": "must_be_absent", + } + ] + assert {entry["registry"] for entry in pypi_checks} == {"pypi", "testpypi"} + assert all(entry["normalized_name"] == "atm-serde" for entry in pypi_checks) + assert all(entry["version_lookup_url"] is None for entry in pypi_checks) + + +def test_registry_status_cli_uses_the_fail_closed_shared_registry_probe( + published_registry_url: str, +) -> None: + """The workflow-facing command exposes the shared successful lookup state.""" + result = subprocess.run( + [ + sys.executable, + str(scripts_root() / "release_artifacts.py"), + "registry-status", + "--url", + published_registry_url, + ], + text=True, + capture_output=True, + check=False, + ) + + assert result.returncode == 0, result.stderr + assert result.stdout == "published\n" + + +def test_release_workflow_enforces_python_release_invariants() -> None: + text = release_workflow_text() + pypi_text = pypi_publish_workflow_text() + action_text = ( + repo_root() / ".github" / "actions" / "setup-python-release-build" / "action.yml" + ).read_text(encoding="utf-8") + + assert "release-plan:" in text + assert "release-target-matrix" in text + assert "python-wheel-matrix" in text + assert "python-sdist-matrix" in text + assert "matrix: ${{ fromJSON(needs.release-plan.outputs.python_wheel_matrix) }}" in text + assert "publish-testpypi:" in text + assert "needs.gate-and-tag.outputs.release_target == 'testpypi'" in text + assert "publish-pypi:" not in text + assert "name: python-sdist-${{ matrix.artifact }}" in text + assert "TEST_PYPI_API_TOKEN" in text + assert "secrets.TEST_PYPI_TOKEN" not in text + assert "--repository testpypi" in text + assert "for pattern in *.tar.gz *.zip *.whl; do" in text + assert "uses: ./.github/actions/setup-python-release-build" in text + assert "update-homebrew:" not in text + assert "publish-winget:" not in text + assert "verify-python-version" in action_text + assert "sync-python-version" in action_text + assert "release_ref" in action_text + assert "pyproject" in action_text + + assert "name: Publish PyPI" in pypi_text + assert "release_tag: ${{ inputs.tag }}" in pypi_text + assert "gh release download" in pypi_text + assert "verify-python-release-assets" in pypi_text + assert "maturin build" not in pypi_text + assert "maturin sdist" not in pypi_text + assert "name: Publish manifest-declared wheels and sdists to TestPyPI" in pypi_text + assert ( + "if: ${{ inputs.target == 'testpypi' && needs.verify-release.outputs.python_upload_tool == 'maturin' }}" + in pypi_text + ) + assert "MATURIN_PYPI_TOKEN: ${{ secrets.TEST_PYPI_API_TOKEN }}" in pypi_text + assert "name: Publish manifest-declared wheels and sdists to PyPI" in pypi_text + assert ( + "if: ${{ inputs.target == 'production' && needs.verify-release.outputs.python_upload_tool == 'maturin' }}" + in pypi_text + ) + assert "MATURIN_PYPI_TOKEN: ${{ secrets.PYPI_API_TOKEN }}" in pypi_text + assert "secrets.TEST_PYPI_TOKEN" not in pypi_text + assert "secrets.PYPI_TOKEN" not in pypi_text + assert "maturin upload --repository \"${PYPI_REPOSITORY}\" --non-interactive --skip-existing dist/*.whl dist/*.tar.gz" in pypi_text + + +def test_release_preflight_requires_each_standardized_secret() -> None: + text = release_preflight_workflow_text() + + assert "Missing required GitHub Actions release secret(s):" in text + for secret_name in ( + "CARGO_REGISTRY_TOKEN", + "HOMEBREW_TAP_TOKEN", + "SCOOP_BUCKET_TOKEN", + "WINGET_GITHUB_TOKEN", + ): + assert secret_name in text + assert "All manifest-required repository secrets are available." in text + assert "preflight-secret-plan" in text + assert '--manifest "${RELEASE_ARTIFACT_MANIFEST}"' in text + assert '\\"${RELEASE_ARTIFACT_MANIFEST}\\"' not in text + assert "Inspect protected Python environment secret metadata (informational)" in text + assert ".environment_secrets[]" in text + assert "environments/${environment_name}/secrets" in text + assert "permissions: read-all" in text + assert "environment:" not in text + assert "Environment-secret metadata is unavailable to GITHUB_TOKEN" in text + assert "Verify repository credential liveness" in text + assert "https://crates.io/api/v1/me" not in text + assert 'Authorization: Bearer ${token}' in text + assert "https://api.github.com/user" in text + assert "rotate or replace it" not in text + assert 'echo "${token}"' not in text + assert 'echo "${!secret_name}"' not in text + assert '${REPOSITORY_SECRET_CHANNELS:-{}}' not in text + assert '${CREDENTIAL_LIVENESS_CHANNELS:-{}}' not in text + assert 'repository_secret_channels_json="${REPOSITORY_SECRET_CHANNELS:-}"' in text + assert 'credential_liveness_channels_json="${CREDENTIAL_LIVENESS_CHANNELS:-}"' in text + assert "REPOSITORY_SECRET_CHANNELS must be a JSON object." in text + assert "CREDENTIAL_LIVENESS_CHANNELS must be a JSON object." in text + assert "already_published_channels" in text + assert "--already-published-channels \"${ALREADY_PUBLISHED_CHANNELS}\"" in text + assert "if result=" not in release_preflight_step_shell("unpublished", "registry_state") + + +@pytest.mark.parametrize( + ("published", "already_published_channels", "expected_success"), + ( + (True, "crates_io", True), + (True, "", False), + (False, "crates_io", True), + ), +) +def test_release_preflight_registry_checks_execute_preserved_channel_exception( + tmp_path: Path, + published: bool, + already_published_channels: str, + expected_success: bool, +) -> None: + """Run the actual unpublished and registry-state shells for retry outcomes.""" + unpublished = run_release_preflight_registry_step( + tmp_path, + release_preflight_step_shell("unpublished", "registry_state"), + published=published, + already_published_channels=already_published_channels, + ) + registry_state = run_release_preflight_registry_step( + tmp_path, + release_preflight_step_shell("registry_state", "package_checks"), + published=published, + already_published_channels=already_published_channels, + ) + + assert (unpublished.returncode == 0) is expected_success, unpublished.stderr + assert (registry_state.returncode == 0) is expected_success, registry_state.stderr + if published and expected_success: + assert "preserved from a prior release run" in unpublished.stdout + assert "preserved from a prior release run" in registry_state.stdout + elif published: + assert "already published" in unpublished.stderr + assert "already published" in registry_state.stderr + + +def test_check_version_unpublished_allows_only_listed_published_channels( + tmp_path: Path, published_registry_url: str +) -> None: + """Cover channel-scoped outcomes across calls; every crate resolves to crates_io.""" + _, manifest = write_repo_fixture(tmp_path, manifest_wheels=["ubuntu-latest"]) + configure_fixture_crates_registry(manifest, published_registry_url) + + preserved = run_fixture_command( + tmp_path, + "check-version-unpublished", + "--version", + "1.1.0", + "--already-published-channels", + "crates_io", + manifest=manifest, + ) + unlisted = run_fixture_command( + tmp_path, + "check-version-unpublished", + "--version", + "1.1.0", + "--already-published-channels", + "pypi", + manifest=manifest, + ) + + assert preserved.returncode == 0, preserved.stderr + assert "crates_io is preserved from a prior release run" in preserved.stdout + assert unlisted.returncode != 0 + assert "release version already published for:" in unlisted.stderr + + +def test_release_gate_readiness_threads_preserved_channel_provenance( + tmp_path: Path, published_registry_url: str +) -> None: + """Readiness forwards channel-scoped retry provenance to the native checker.""" + workspace, manifest = write_repo_fixture(tmp_path, manifest_wheels=["ubuntu-latest"]) + configure_fixture_crates_registry(manifest, published_registry_url) + for crate in tomllib.loads(manifest.read_text(encoding="utf-8"))["crates"]: + crate_manifest = tmp_path / crate["cargo_toml"] + crate_manifest.write_text( + crate_manifest.read_text(encoding="utf-8").replace( + 'version = "1.1.0"', "version.workspace = true" + ), + encoding="utf-8", + ) + + preserved = run_release_gate_readiness( + tmp_path, + manifest=manifest, + workspace=workspace, + already_published_channels="crates_io", + ) + unlisted = run_release_gate_readiness( + tmp_path, + manifest=manifest, + workspace=workspace, + already_published_channels="pypi", + ) + + assert preserved.returncode == 0, preserved.stderr + assert "PASS - release gate checks satisfied" in preserved.stdout + assert unlisted.returncode != 0 + assert "release version already published for:" in unlisted.stderr + + +def test_release_gate_final_threads_preserved_channel_provenance( + tmp_path: Path, published_registry_url: str +) -> None: + """The root Release workflow's final gate honors prior channel success.""" + workspace, manifest = write_repo_fixture(tmp_path, manifest_wheels=["ubuntu-latest"]) + configure_fixture_crates_registry(manifest, published_registry_url) + for crate in tomllib.loads(manifest.read_text(encoding="utf-8"))["crates"]: + crate_manifest = tmp_path / crate["cargo_toml"] + crate_manifest.write_text( + crate_manifest.read_text(encoding="utf-8").replace( + 'version = "1.1.0"', "version.workspace = true" + ), + encoding="utf-8", + ) + + preserved = run_release_gate_readiness( + tmp_path, + manifest=manifest, + workspace=workspace, + mode="final", + release_ref="origin/main", + already_published_channels="crates_io", + ) + + assert preserved.returncode == 0, preserved.stderr + assert "mode=final release_ref=origin/main" in preserved.stdout + assert "PASS - release gate checks satisfied" in preserved.stdout + + +def test_release_tag_reuse_requires_verified_ancestor_and_candidate_lineage( + tmp_path: Path, +) -> None: + """A recovery keeps an immutable tag only when both ancestry checks hold.""" + accepted = run_release_tag_step( + tmp_path / "accepted", tag_is_main_ancestor=True, candidate_is_tag_ancestor=True + ) + diverged = run_release_tag_step( + tmp_path / "diverged", tag_is_main_ancestor=False, candidate_is_tag_ancestor=True + ) + wrong_candidate = run_release_tag_step( + tmp_path / "wrong-candidate", tag_is_main_ancestor=True, candidate_is_tag_ancestor=False + ) + + assert accepted.returncode == 0, accepted.stderr + assert "reusing immutable tag while building from origin/main" in accepted.stdout + assert (tmp_path / "accepted" / "github-output").read_text(encoding="utf-8") == "build_ref=main-sha\n" + assert diverged.returncode != 0 + assert "is not an ancestor of origin/main" in diverged.stderr + assert wrong_candidate.returncode != 0 + assert "does not descend from release-candidate-v1.5.0" in wrong_candidate.stderr + + +def test_release_tag_step_emits_resolved_main_sha_for_every_output_path( + tmp_path: Path, +) -> None: + """Reuse, creation, and rehearsal pin downstream checkouts to the verified SHA.""" + reused = run_release_tag_step( + tmp_path / "reused", tag_is_main_ancestor=True, candidate_is_tag_ancestor=True + ) + created = run_release_tag_step( + tmp_path / "created", + tag_is_main_ancestor=True, + candidate_is_tag_ancestor=True, + tag_exists=False, + ) + rehearsal = run_release_tag_step( + tmp_path / "rehearsal", + tag_is_main_ancestor=True, + candidate_is_tag_ancestor=True, + target="testpypi", + ) + + for name, result in (("reused", reused), ("created", created), ("rehearsal", rehearsal)): + assert result.returncode == 0, result.stderr + assert (tmp_path / name / "github-output").read_text(encoding="utf-8") == ( + "build_ref=main-sha\n" + ) + + +def test_release_tag_reuse_verifies_real_git_ancestry(tmp_path: Path) -> None: + """Tag reuse works only for real remote tag/candidate/main ancestry.""" + accepted_repo = write_real_release_tag_fixture(tmp_path / "accepted", "accepted") + diverged_repo = write_real_release_tag_fixture(tmp_path / "diverged", "diverged") + wrong_candidate_repo = write_real_release_tag_fixture( + tmp_path / "wrong-candidate", "wrong-candidate" + ) + + accepted = run_release_tag_step_in_git_fixture(accepted_repo) + diverged = run_release_tag_step_in_git_fixture(diverged_repo) + wrong_candidate = run_release_tag_step_in_git_fixture(wrong_candidate_repo) + + assert accepted.returncode == 0, accepted.stderr + assert "reusing immutable tag while building from origin/main" in accepted.stdout + assert (accepted_repo / "github-output").read_text(encoding="utf-8") == ( + f"build_ref={git_fixture_command(accepted_repo, 'rev-parse', 'origin/main')}\n" + ) + assert diverged.returncode != 0 + assert "is not an ancestor of origin/main" in diverged.stderr + assert wrong_candidate.returncode != 0 + assert "does not descend from release-candidate-v1.5.0" in wrong_candidate.stderr + + +def test_root_release_workflow_threads_retry_provenance_and_builds_from_main() -> None: + """The workflow supplies retry provenance and separates immutable tag from build ref.""" + workflow = release_workflow_text() + + assert "already_published_channels:" in workflow + assert "ALREADY_PUBLISHED_CHANNELS: ${{ inputs.already_published_channels }}" in workflow + assert '"${ALREADY_PUBLISHED_CHANNELS}"' in workflow + assert "id: release_gate" in workflow + assert "main_sha='${{ steps.release_gate.outputs.release_sha }}'" in workflow + assert 'git tag "$tag" "$main_sha"' in workflow + assert "build_ref: ${{ steps.release-ref.outputs.build_ref }}" in workflow + assert workflow.count('echo "build_ref=$main_sha" >> "$GITHUB_OUTPUT"') == 1 + assert workflow.count("needs.gate-and-tag.outputs.build_ref") == 9 + assert "gate-and-tag.outputs.release_ref" not in workflow + assert "ref: ${{ needs.gate-and-tag.outputs.release_tag }}" not in workflow + assert "ref: ${{ needs.gate-and-tag.outputs.release_ref }}" not in workflow + + +def test_release_preflight_channel_results_executes_nonempty_json_without_legacy_brace_corruption( + tmp_path: Path, +) -> None: + """Run the workflow shell and prove the historical default syntax is rejected.""" + _, manifest = write_repo_fixture(tmp_path, manifest_wheels=["ubuntu-latest"]) + shell = release_preflight_channel_results_shell() + + fixed_output = tmp_path / "fixed-output.txt" + fixed = run_release_preflight_channel_results_shell( + shell, manifest=manifest, output=fixed_output + ) + assert fixed.returncode == 0, fixed.stderr + payload = fixed_output.read_text(encoding="utf-8").split( + "channel_preflight_results</dev/null \\ + || { echo 'REPOSITORY_SECRET_CHANNELS must be a JSON object.' >&2; exit 1; } +jq -e 'type == \"object\"' <<<\"${credential_liveness_channels_json}\" >/dev/null \\ + || { echo 'CREDENTIAL_LIVENESS_CHANNELS must be a JSON object.' >&2; exit 1; } +""" + assert fixed_preamble in shell + legacy_shell = shell.replace(fixed_preamble, "").replace( + '"${repository_secret_channels_json}"', '"${REPOSITORY_SECRET_CHANNELS:-{}}"' + ).replace( + '"${credential_liveness_channels_json}"', '"${CREDENTIAL_LIVENESS_CHANNELS:-{}}"' + ) + legacy = run_release_preflight_channel_results_shell( + legacy_shell, manifest=manifest, output=tmp_path / "legacy-output.txt" + ) + assert legacy.returncode != 0 + assert "invalid JSON" in legacy.stderr + + +def test_channel_recovery_workflows_require_a_published_release() -> None: + guard_text = published_release_guard_text() + pypi_text = pypi_publish_workflow_text() + homebrew_text = homebrew_publish_workflow_text() + winget_text = winget_publish_workflow_text() + scoop_text = scoop_publish_workflow_text() + + assert "No published GitHub Release found" in guard_text + assert "is still a draft" in guard_text + assert "optional SemVer prerelease/build metadata" in guard_text + assert "REQUIRED_ASSET_PATTERNS" in guard_text + + for workflow_text in (pypi_text, homebrew_text, winget_text, scoop_text): + assert "workflow_dispatch:" in workflow_text + assert "uses: ./.github/actions/verify-published-release" in workflow_text + assert "release_tag: ${{ inputs.tag }}" in workflow_text + assert "gate-and-tag" not in workflow_text + + assert "WINGET_GITHUB_TOKEN" in winget_text + assert "channel-config" in winget_text + assert "HOMEBREW_TAP_TOKEN" in homebrew_text + assert "ref: ${{ inputs.tag }}" in homebrew_text + assert "channel-config" in homebrew_text + assert "SCOOP_BUCKET_TOKEN" in scoop_text + assert "channel-config" in scoop_text + assert "Render Scoop manifest with published renderer" in scoop_text + assert 'MANIFEST_TEMPLATE: ${{ fromJSON(needs.verify-release.outputs.channel_config).channel.manifest_template }}' in scoop_text + assert ".replace(placeholder, value)" not in scoop_text + assert "cargo run --quiet --manifest-path release-source/Cargo.toml" not in scoop_text + assert "PUBLISHED_RENDERER" in scoop_text + assert "Checkout workflow support" in scoop_text + assert "uses: ./.github/actions/setup-renderer" in scoop_text + assert "uses: ./.github/actions/extract-published-renderer" not in scoop_text + + assert "Render manifest-selected formulas with the published renderer" in homebrew_text + assert '--tag "${{ inputs.tag }}"' in homebrew_text + assert 'channel["formulas"]' in homebrew_text + assert 'formula["path"]' in homebrew_text + assert 'formula["template"]' in homebrew_text + assert ".replace(placeholder, value)" not in homebrew_text + assert "PUBLISHED_RENDERER" in homebrew_text + assert "Checkout workflow support" in homebrew_text + assert "uses: ./.github/actions/setup-renderer" in homebrew_text + assert "uses: ./.github/actions/extract-published-renderer" not in homebrew_text + assert "install_block" not in homebrew_text + assert "bundled_paths" in homebrew_text + + renderer_action = ( + repo_root() + / ".github" + / "actions" + / "setup-renderer" + / "action.yml" + ).read_text(encoding="utf-8") + assert "bootstrap_sc_compose.py" in renderer_action + assert "--write-cli" in renderer_action + assert "PUBLISHED_RENDERER=${renderer}" in renderer_action + assert "renderer-path=${renderer}" in renderer_action + + linux_deps_action = ( + repo_root() + / ".github" + / "actions" + / "install-linux-native-deps" + / "action.yml" + ).read_text(encoding="utf-8") + assert "libwebkit2gtk-4.1-dev" in linux_deps_action + assert "libwayland-dev" in linux_deps_action + for workflow_text in (release_workflow_text(), release_preflight_workflow_text(), crates_publish_workflow_text()): + assert "uses: ./.github/actions/install-linux-native-deps" in workflow_text + + +def render_release_template( + tmp_path: Path, template: str, variables: dict[str, object] +) -> str: + import sc_compose + + request = sc_compose.ComposeRequest( + root=repo_root(), + mode=sc_compose.ComposeMode.file(template), + vars_input=variables, + policy=sc_compose.ComposePolicy(strict_undeclared_variables=False), + ) + return sc_compose.compose_file(request).rendered_text + + +def test_release_channel_templates_render_to_valid_ruby_and_json(tmp_path: Path) -> None: + formula = render_release_template( + tmp_path, + "release/homebrew/formula.rb.j2", + { + "formula_class": "ScCompose", + "description": "Standalone template composition CLI", + "homepage": "https://github.com/randlee/sc-compose", + "license": "MIT", + "version": "1.4.2", + "macos_arm_url": "https://example.invalid/arm.tar.gz", + "macos_arm_sha256": "a" * 64, + "macos_intel_url": "https://example.invalid/intel.tar.gz", + "macos_intel_sha256": "b" * 64, + "linux_url": "https://example.invalid/linux.tar.gz", + "linux_sha256": "c" * 64, + "test_binary": "sc-compose-daemon", + "test_command": "--help", + "test_output": "Standalone template composition CLI", + "binary_paths": ["bin/sc-compose", "bin/sc-compose-daemon"], + "bundled_paths": [ + { + "destination_components": ["pkgshare", "examples"], + "source_glob": "share/sc-compose/examples/*", + } + ], + }, + ) + ruby = subprocess.run( + ["ruby", "-c"], input=formula, text=True, capture_output=True, check=False + ) + assert ruby.returncode == 0, ruby.stderr + assert 'bin.install "bin/sc-compose"' in formula + assert 'bin.install "bin/sc-compose-daemon"' in formula + assert 'shell_output("#{bin}/" + "sc-compose-daemon"' in formula + assert '("pkgshare"/"examples").install Dir["share/sc-compose/examples/*"]' in formula + + scoop = render_release_template( + tmp_path, + "release/scoop/manifest.json.j2", + { + "version": "1.4.2", + "description": 'Quoted "description"', + "homepage": "https://github.com/randlee/sc-compose", + "license": "MIT", + "windows_url": "https://example.invalid/windows.zip", + "windows_sha256": "d" * 64, + "extract_dir": "sc-compose_1.4.2_x86_64-pc-windows-msvc", + "binary": "bin/sc-compose.exe", + }, + ) + manifest = json.loads(scoop) + assert manifest["description"] == 'Quoted "description"' + assert manifest["architecture"]["64bit"]["bin"] == "bin/sc-compose.exe" + + +def test_homebrew_formula_tracks_and_binaries_are_documented() -> None: + required = ( + repo_root() / "docs" / "publish-kit-requirements.md", + repo_root() / "docs" / "sprints" / "fix-pr507-release-channel-runtime-checklist.md", + repo_root() / "RELEASING.md", + repo_root() / "CHANGELOG.md", + ) + if not all(path.is_file() for path in required): + pytest.skip("consumer does not include source-repository publishing documentation") + requirements = (repo_root() / "docs" / "publish-kit-requirements.md").read_text( + encoding="utf-8" + ) + sprint = ( + repo_root() / "docs" / "sprints" / "fix-pr507-release-channel-runtime-checklist.md" + ).read_text(encoding="utf-8") + releasing = (repo_root() / "RELEASING.md").read_text(encoding="utf-8") + changelog = (repo_root() / "CHANGELOG.md").read_text(encoding="utf-8") + + for text in (requirements, sprint, releasing, changelog): + assert "release_track" in text + assert "prerelease" in text + assert "binaries" in text + + +def test_publish_kit_guidance_is_manifest_driven_and_token_non_disclosing() -> None: + required = ( + repo_root() / "docs" / "publishing-agent.md", + repo_root() / "docs" / "release-checklist.md", + repo_root() / "docs" / "eval" / "publishing" / "publish-kit-agent-eval-plan.md", + repo_root() / "docs" / "eval" / "README.md", + ) + if not all(path.is_file() for path in required): + pytest.skip("consumer does not include source-repository publishing documentation") + publisher_text = (repo_root() / ".claude" / "agents" / "publisher.md").read_text( + encoding="utf-8" + ) + guide_text = (repo_root() / "docs" / "publishing-agent.md").read_text(encoding="utf-8") + checklist_text = (repo_root() / "docs" / "release-checklist.md").read_text( + encoding="utf-8" + ) + channel_contract_text = ( + repo_root() / ".claude" / "skills" / "publishing" / "ref" / "channel-contracts.md" + ).read_text(encoding="utf-8") + eval_plan_text = ( + repo_root() / "docs" / "eval" / "publishing" / "publish-kit-agent-eval-plan.md" + ).read_text(encoding="utf-8") + eval_convention_text = (repo_root() / "docs" / "eval" / "README.md").read_text( + encoding="utf-8" + ) + publishing_skill_text = (repo_root() / ".claude" / "skills" / "publishing" / "SKILL.md").read_text( + encoding="utf-8" + ) + release_state_text = ( + repo_root() / ".claude" / "skills" / "publishing" / "ref" / "release-state-strategy.md" + ).read_text(encoding="utf-8") + preflight_template_text = ( + repo_root() / ".claude" / "skills" / "publishing" / "preflight.xml.j2" + ).read_text(encoding="utf-8") + publish_template_text = ( + repo_root() / ".claude" / "skills" / "publishing" / "publish.xml.j2" + ).read_text(encoding="utf-8") + preflight_eval_text = ( + repo_root() / ".claude" / "skills" / "publishing" / "evals" / "publisher-preflight.md" + ).read_text(encoding="utf-8") + recovery_eval_text = ( + repo_root() / ".claude" / "skills" / "publishing" / "evals" / "publisher-recovery.md" + ).read_text(encoding="utf-8") + inquiry_eval_text = ( + repo_root() / ".claude" / "skills" / "publishing" / "evals" / "channel-name-inquiry.md" + ).read_text(encoding="utf-8") + + for text in (guide_text, checklist_text): + assert "channel-dispatch-plan" in text + assert "PYPI_TOKEN" not in text + assert "TEST_PYPI_TOKEN" not in text + assert "sc-compose" not in text + + assert "renderer-contract.md" in publisher_text + assert "role-specific background workers" in publisher_text + assert "outcomes are keyed by channel" in ( + repo_root() / "docs" / "publish-kit-requirements.md" + ).read_text(encoding="utf-8") + assert '"status": "passed|failed|blocked"' in publisher_text + assert '"passed|failed|blocked|required"' not in publisher_text + assert '"required_checks": [{"kind": ""' in publisher_text + assert "`required_checks` lists contract checks deliberately not run" in publisher_text + channel_protocol_text = ( + repo_root() / ".claude" / "agents" / "publisher-channel-protocol.md" + ).read_text(encoding="utf-8") + assert "never a check-result status" in channel_protocol_text + assert '"required_checks": [{"kind": ""' in channel_protocol_text + assert '"success": false' in publisher_text + assert "retain `data`" in publisher_text + assert "Do not retry a `blocked` channel" in publisher_text + assert "Retry only the channel" in publisher_text + assert "Never ask whether a token exists" in publisher_text + assert "preflight-secret-plan" in publisher_text + assert "protected-environment secret metadata" in guide_text + assert "version: 1.6.5" in publisher_text + assert "closed-world:" in publisher_text + assert "Never invent a tag, version, ref" in publisher_text + assert "Emit an observed `checks` entry" in publisher_text + assert "Send the assignment's named recipient" in publisher_text + assert "### Synthetic-evaluation response checklist" in publisher_text + assert "Every channel has `worker.role`, `worker.child_task_id`, and" in publisher_text + assert "Send `team-lead`" not in publisher_text + assert "named recipient" in publisher_text + for template_text in (preflight_template_text, publish_template_text): + assert "- recipient" in template_text + assert "{{ recipient }}" in template_text + assert "Send {{ recipient }}" in template_text + assert publisher_text.count( + '"data": {"tag": "v", "commit": "", "channels": []}' + ) == 2 + for eval_text in (preflight_eval_text, recovery_eval_text): + normalized_eval_text = " ".join(eval_text.split()) + assert "evaluator/coordinator identity" in normalized_eval_text + assert "not the evaluated publisher teammate" in normalized_eval_text + assert "## Inputs" in publisher_text + assert "## Output Format" in publisher_text + assert "## Error Handling" in publisher_text + assert "## Constraints" in publisher_text + registry_text = (repo_root() / ".claude" / "agents" / "registry.yaml").read_text( + encoding="utf-8" + ) + contracts = tomllib.loads( + (repo_root() / "release" / "publish-channel-contracts.toml.j2").read_text( + encoding="utf-8" + ) + )["channels"] + assert 'publisher:\n version: 1.6.5' in registry_text + for channel_agent in ( + "crates-io-publisher", + "pypi-publisher", + "github-release-publisher", + "homebrew-publisher", + "winget-publisher", + "scoop-publisher", + ): + assert f"{channel_agent}:" in registry_text + agent_path = repo_root() / ".claude" / "agents" / f"{channel_agent}.md" + assert agent_path.is_file() + agent_text = agent_path.read_text(encoding="utf-8") + assert "publisher-channel-protocol.md" in agent_text + assert ".claude/skills/publishing/ref/channel-contracts.md" in agent_text + assert "spawn_policy: background_agent_required" in agent_text + assert not (repo_root() / ".claude" / "agents" / "publisher-channel-worker.md").exists() + assert { + contract["agent"] for contract in contracts.values() + } == { + "crates-io-publisher", + "pypi-publisher", + "github-release-publisher", + "homebrew-publisher", + "winget-publisher", + "scoop-publisher", + } + assert contracts["pypi"]["environment_secrets"] == [ + {"environment": "pypi", "name": "PYPI_API_TOKEN"}, + {"environment": "testpypi", "name": "TEST_PYPI_API_TOKEN"}, + ] + assert 'publishing:\n version: 1.1.0' in registry_text + assert "sole channel-contract source" in channel_contract_text + assert "public-registry-inquiry-plan" in channel_contract_text + assert "apparently_available" in channel_contract_text + assert "not a reservation" in channel_contract_text + assert "candidate-tag validation failure" in publisher_text + assert "Use `PREFLIGHT.NOT_READY` as the top-level error code" in publisher_text + assert "still launch one read-only" in publisher_text + assert "result and child-task/result references. This is required live fanout" in publisher_text + assert "union of\n`root_channels` and `post_release_channels` in `preflight-secret-plan`" in publisher_text + assert "channel-dispatch-plan` alone contains only post-release work" in publisher_text + assert '"child_task_id": ""' in publisher_text + assert "simulated missing credential" in eval_plan_text + assert "not create a tag" in eval_plan_text + assert "## Goals" in eval_plan_text + assert "## Expected Outcomes" in eval_plan_text + assert "Haiku or Luna" in eval_plan_text + assert "top-level\n error remains `PREFLIGHT.NOT_READY`" in eval_plan_text + assert "role-specific background worker" in eval_plan_text + assert "background tasks, not teammates or panes" in eval_plan_text + assert "full `sc-compose` ATM team member" in eval_plan_text + assert "dedicated\n tmux pane" in eval_plan_text + assert "rmux claude publisher --team sc-compose --model haiku" in eval_plan_text + assert "rmux codex publisher --team sc-compose --model luna" in eval_plan_text + assert "ATM_IDENTITY=publisher" in eval_plan_text + assert "ATM_TEAM=sc-compose" in eval_plan_text + assert "configured\n hooks" in eval_plan_text + assert "post-run ATM questioning" in eval_plan_text + assert "do not use\n`channel-dispatch-plan` as the worker inventory" in eval_plan_text + assert "Every evaluation document must state:" in eval_convention_text + assert ".claude/skills/publishing/ref/release-state-strategy.md" in publisher_text + assert "ref/release-state-strategy.md" in publishing_skill_text + assert "rmux claude publisher" in publishing_skill_text + assert "rmux codex publisher" in publishing_skill_text + assert "--team " in publishing_skill_text + assert "identity is exactly `publisher`" in publishing_skill_text + assert "evals/publisher-preflight.md" in publishing_skill_text + assert "evals/publisher-recovery.md" in publishing_skill_text + assert "evals/channel-name-inquiry.md" in publishing_skill_text + assert "role-specific background worker for the read-only inquiry" in publishing_skill_text + assert "publish-channel-contracts.toml" in publishing_skill_text + assert "Code only on `feature/*` or `fix/*`" in release_state_text + assert "Code on `develop`" in release_state_text + assert "Code on `main`" in release_state_text + assert "Code on `release/*`" in release_state_text + assert "final preflight on the exact `main` commit" in release_state_text + assert "same authorized release ref and" in publisher_text + assert "tag. It must read the full ordered manifest" in publisher_text + assert "already-live" in publisher_text + for template_text in (preflight_template_text, publish_template_text): + assert 'assignee="publisher"' in template_text + assert "release-state-strategy.md" in template_text + assert "manifest_path" in template_text + for eval_text in (preflight_eval_text, recovery_eval_text, inquiry_eval_text): + assert "## Goal" in eval_text + assert "## Expected outcomes" in eval_text + assert "fresh" in eval_text + assert "fenced JSON" in eval_text + assert "must not" in eval_text + for eval_text in (preflight_eval_text, recovery_eval_text): + assert "manifest path" in eval_text + assert "Do not hardcode a package" in eval_text + for text in ( + publishing_skill_text, + preflight_template_text, + publish_template_text, + preflight_eval_text, + recovery_eval_text, + ): + assert "sc-compose" not in text + + +def test_publishing_task_templates_render_recipient_contract(tmp_path: Path) -> None: + if renderer_binary() is None: + pytest.skip("consumer does not include a sc-compose renderer workspace") + cases = ( + ( + ".claude/skills/publishing/preflight.xml.j2", + { + "task_id": "EVAL-PREFLIGHT", + "recipient": "evaluator-preflight", + "release_version": "1.4.2", + "candidate_ref": "develop", + "candidate_commit": "deadbeef", + "starting_state": "develop", + "preflight_stage": "readiness", + "worktree_path": "/tmp/eval", + "branch": "develop", + "manifest_path": "release/publish-artifacts.toml", + "already_published_channels": "crates_io", + }, + ), + ( + ".claude/skills/publishing/publish.xml.j2", + { + "task_id": "EVAL-RECOVERY", + "recipient": "evaluator-recovery", + "release_version": "1.4.2", + "release_ref": "refs/tags/v1.4.2", + "release_commit": "deadbeef", + "operation": "retry-failed-channels", + "failed_channels": "crates_io", + "worktree_path": "/tmp/eval", + "manifest_path": "release/publish-artifacts.toml", + }, + ), + ) + + for template_path, context in cases: + rendered = render_release_template(tmp_path, template_path, context) + root = ET.fromstring(rendered) + + assert root.findtext("recipient") == context["recipient"] + assert f"Send {context['recipient']}" in rendered + if template_path.endswith("preflight.xml.j2"): + assert root.findtext("release/already-published-channels") == "crates_io" + + +def test_release_preflight_collects_independent_failures_before_denial() -> None: + preflight_text = (repo_root() / ".github" / "workflows" / "release-preflight.yml").read_text( + encoding="utf-8" + ) + + assert "Deny release after complete preflight summary" in preflight_text + assert "channel_preflight_results" in preflight_text + assert "channel-preflight-results" in preflight_text + assert "Emit manifest-derived per-channel preflight results" in preflight_text + assert "Preflight complete: failed=[%s] blocked=[%s]" in preflight_text + assert preflight_text.count("continue-on-error: true") >= 12 + assert "failures=()" in preflight_text + assert "steps.secret_plan.outcome == 'success'" in preflight_text + assert "Verify registry versions and new names" in preflight_text + assert "public-registry-check-plan" in preflight_text + assert preflight_text.count("registry-status --url") == 2 + assert "status_code()" not in preflight_text + assert "published:published:informational" in preflight_text + assert "200:200:informational" not in preflight_text + assert "REGISTRY_STATE" in preflight_text + + +def test_release_workflow_collects_wheels_without_redundant_zip_sweep() -> None: + text = release_workflow_text() + + assert ( + "find artifacts -type f \\( -name '*.tar.gz' -o -name '*.zip' \\) -exec mv {} release/ \\;" + in text + ) + assert "find artifacts -type f -name '*.whl' -exec mv {} release/ \\;" in text + assert "find artifacts -type f \\( -name '*.zip' -o -name '*.whl' \\)" not in text + + +def test_release_workflow_rehearsal_mode_avoids_production_side_effects() -> None: + text = release_workflow_text() + + assert 'echo "Rehearsal mode: validating release tag ${tag} locally only; not pushing any tag to origin"' in text + assert text.count("echo \"build_ref=$main_sha\" >> \"$GITHUB_OUTPUT\"") == 1 + assert "needs.gate-and-tag.outputs.release_target == 'production'" in text + + +def test_release_workflow_checks_out_repo_before_local_python_setup_action() -> None: + text = release_workflow_text() + + wheels_job = """ build-python-wheels: + if: ${{ needs.release-plan.outputs.has_python_wheels == 'true' }} + needs: [gate-and-tag, release-plan] + strategy: + fail-fast: false + matrix: ${{ fromJSON(needs.release-plan.outputs.python_wheel_matrix) }} + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ needs.gate-and-tag.outputs.build_ref }} + - uses: ./.github/actions/setup-python-release-build""" + sdist_job = """ build-python-sdists: + if: ${{ needs.release-plan.outputs.has_python_sdists == 'true' }} + needs: [gate-and-tag, release-plan] + strategy: + fail-fast: false + matrix: ${{ fromJSON(needs.release-plan.outputs.python_sdist_matrix) }} + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ needs.gate-and-tag.outputs.build_ref }} + - uses: ./.github/actions/setup-python-release-build""" + + assert wheels_job in text + assert sdist_job in text + assert "matrix.cargo_manifest" in text + assert "matrix.pyproject" in text + + +def test_python_package_metadata_uses_local_readme_for_sdist() -> None: + if not (repo_root() / "bindings" / "python").is_dir(): + pytest.skip("consumer does not include a Python binding") + pyproject_text = python_pyproject_text() + cargo_toml_text = python_cargo_toml_text() + + assert 'readme = "README.md"' in pyproject_text + assert 'readme = "README.md"' in cargo_toml_text + assert "../../README.md" not in pyproject_text + assert "../../README.md" not in cargo_toml_text + + +def write_readme_fixture( + tmp_path: Path, + *, + dependency_version: str, + status_version: str, + stability_minor: str, + dependency_crate: str = "sc-composer", +) -> tuple[Path, Path, Path]: + workspace = tmp_path / "Cargo.toml" + workspace.write_text( + "\n".join(["[workspace.package]", 'version = "1.2.0"', ""]), + encoding="utf-8", + ) + readme = tmp_path / "README.md" + readme.write_text( + "\n".join( + [ + "## Library usage", + "", + "```toml", + "[dependencies]", + f'{dependency_crate} = "{dependency_version}"', + "```", + "", + "## Status", + "", + "| | |", + "|-|-|", + f"| Version | {status_version} |", + "| MSRV | Rust 1.94.1 |", + f"| Stability | stable {stability_minor} release line |", + "", + ] + ), + encoding="utf-8", + ) + manifest = tmp_path / "release" / "publish-artifacts.toml" + manifest.parent.mkdir(parents=True) + manifest.write_text( + "\n".join( + [ + "[project]", + f'readme_dependency_crate = "{dependency_crate}"', + "", + "[[crates]]", + 'artifact = "readme-dependency"', + f'package = "{dependency_crate}"', + 'cargo_toml = "crates/readme-dependency/Cargo.toml"', + "publish_order = 1", + "wait_after_publish_seconds = 0", + "", + ] + ), + encoding="utf-8", + ) + return workspace, readme, manifest + + +def run_sync_readme_version( + workspace: Path, readme: Path, manifest: Path +) -> subprocess.CompletedProcess[str]: + return subprocess.run( + [ + sys.executable, + "scripts/release_artifacts.py", + "sync-readme-version", + "--manifest", + str(manifest), + "--workspace-toml", + str(workspace), + "--readme", + str(readme), + ], + cwd=Path(__file__).resolve().parents[2], + text=True, + capture_output=True, + check=False, + ) + + +def run_verify_readme_version( + workspace: Path, readme: Path, manifest: Path +) -> subprocess.CompletedProcess[str]: + return subprocess.run( + [ + sys.executable, + "scripts/release_artifacts.py", + "verify-readme-version", + "--manifest", + str(manifest), + "--workspace-toml", + str(workspace), + "--readme", + str(readme), + ], + cwd=Path(__file__).resolve().parents[2], + text=True, + capture_output=True, + check=False, + ) + + +def write_version_lockstep_fixture( + tmp_path: Path, + *, + python_version: str = "1.4.0", + crate_inherits_workspace_version: bool = True, +) -> tuple[Path, Path]: + workspace = tmp_path / "Cargo.toml" + workspace.write_text( + "[workspace.package]\nversion = \"1.4.0\"\n", + encoding="utf-8", + ) + for relative_path in ( + "crates/sc-sha/Cargo.toml", + "crates/sc-composer/Cargo.toml", + "crates/sc-compose/Cargo.toml", + "bindings/python/Cargo.toml", + "bindings/sc-sha-python/Cargo.toml", + ): + path = tmp_path / relative_path + path.parent.mkdir(parents=True, exist_ok=True) + version = "version.workspace = true" if crate_inherits_workspace_version else 'version = "1.3.1"' + path.write_text( + "[package]\nname = \"fixture\"\n" + version + "\n", + encoding="utf-8", + ) + for relative_path in ( + "bindings/python/pyproject.toml", + "bindings/sc-sha-python/pyproject.toml", + ): + path = tmp_path / relative_path + path.write_text( + f'[project]\nname = "fixture"\nversion = "{python_version}"\n', + encoding="utf-8", + ) + manifest = tmp_path / "release" / "publish-artifacts.toml" + manifest.parent.mkdir(parents=True, exist_ok=True) + manifest.write_text( + "\n".join( + [ + "[[crates]]", + 'artifact = "sc-sha"', + 'package = "fixture"', + 'cargo_toml = "crates/sc-sha/Cargo.toml"', + "publish_order = 1", + "wait_after_publish_seconds = 0", + "", + "[[python_packages]]", + 'artifact = "sc-compose-python"', + 'package = "fixture"', + 'manifest = "bindings/python/pyproject.toml"', + 'module = "fixture"', + 'publish = "pypi"', + "", + "[[python_packages]]", + 'artifact = "sc-sha-python"', + 'package = "fixture-sha"', + 'manifest = "bindings/sc-sha-python/pyproject.toml"', + 'module = "fixture_sha"', + 'publish = "pypi"', + "", + "[[python_distributions]]", + 'name = "fixture"', + 'source = "bindings/python"', + 'cargo_manifest = "bindings/python/Cargo.toml"', + "sdist = true", + 'wheels = ["ubuntu-latest"]', + "", + "[[python_distributions]]", + 'name = "fixture-sha"', + 'source = "bindings/sc-sha-python"', + 'cargo_manifest = "bindings/sc-sha-python/Cargo.toml"', + "sdist = true", + 'wheels = ["ubuntu-latest"]', + "", + ] + ), + encoding="utf-8", + ) + return workspace, manifest + + +def run_verify_version_lockstep(workspace: Path, manifest: Path) -> subprocess.CompletedProcess[str]: + return subprocess.run( + [ + sys.executable, + "scripts/release_artifacts.py", + "verify-version-lockstep", + "--manifest", + str(manifest), + "--workspace-toml", + str(workspace), + ], + cwd=Path(__file__).resolve().parents[2], + text=True, + capture_output=True, + check=False, + ) + + +def test_verify_readme_version_passes_when_readme_matches_workspace(tmp_path: Path) -> None: + workspace, readme, manifest = write_readme_fixture( + tmp_path, dependency_version="1.2.0", status_version="1.2.0", stability_minor="1.2" + ) + + result = run_verify_readme_version(workspace, readme, manifest) + + assert result.returncode == 0, result.stderr + assert "readme version verification passed" in result.stdout + + +def test_verify_readme_version_rejects_stale_dependency_example(tmp_path: Path) -> None: + workspace, readme, manifest = write_readme_fixture( + tmp_path, dependency_version="1.1.0", status_version="1.2.0", stability_minor="1.2" + ) + + result = run_verify_readme_version(workspace, readme, manifest) + + assert result.returncode != 0 + assert "sc-composer dependency example" in result.stderr + + +def test_verify_readme_version_rejects_stale_status_table(tmp_path: Path) -> None: + workspace, readme, manifest = write_readme_fixture( + tmp_path, dependency_version="1.2.0", status_version="1.1.0", stability_minor="1.1" + ) + + result = run_verify_readme_version(workspace, readme, manifest) + + assert result.returncode != 0 + assert "Status table Version row" in result.stderr + assert "Status table Stability row" in result.stderr + + +def test_sync_readme_version_rewrites_stale_references(tmp_path: Path) -> None: + workspace, readme, manifest = write_readme_fixture( + tmp_path, dependency_version="1.1.0", status_version="1.1.0", stability_minor="1.1" + ) + + sync_result = run_sync_readme_version(workspace, readme, manifest) + + assert sync_result.returncode == 0, sync_result.stderr + assert "synced 3 readme version reference(s) to 1.2.0" in sync_result.stdout + + verify_result = run_verify_readme_version(workspace, readme, manifest) + assert verify_result.returncode == 0, verify_result.stderr + + +def test_readme_version_commands_use_the_manifest_declared_dependency_crate( + tmp_path: Path, +) -> None: + workspace, readme, manifest = write_readme_fixture( + tmp_path, + dependency_crate="fixture-composer", + dependency_version="1.1.0", + status_version="1.2.0", + stability_minor="1.2", + ) + + sync_result = run_sync_readme_version(workspace, readme, manifest) + + assert sync_result.returncode == 0, sync_result.stderr + assert 'fixture-composer = "1.2.0"' in readme.read_text(encoding="utf-8") + verify_result = run_verify_readme_version(workspace, readme, manifest) + assert verify_result.returncode == 0, verify_result.stderr + + +def test_verify_version_lockstep_accepts_all_release_version_sources(tmp_path: Path) -> None: + result = run_verify_version_lockstep(*write_version_lockstep_fixture(tmp_path)) + + assert result.returncode == 0, result.stderr + assert "version lockstep verification passed" in result.stdout + + +def test_verify_version_lockstep_rejects_non_inherited_crate_version(tmp_path: Path) -> None: + result = run_verify_version_lockstep( + *write_version_lockstep_fixture(tmp_path, crate_inherits_workspace_version=False) + ) + + assert result.returncode != 0 + assert "crates/sc-sha/Cargo.toml" in result.stderr + assert "must inherit workspace.package.version" in result.stderr + + +def test_verify_version_lockstep_rejects_python_package_drift(tmp_path: Path) -> None: + result = run_verify_version_lockstep( + *write_version_lockstep_fixture(tmp_path, python_version="1.3.1") + ) + + assert result.returncode != 0 + assert "bindings/python/pyproject.toml" in result.stderr + assert "[project].version mismatch" in result.stderr + + +def test_build_plan_selects_the_manifest_declared_python_upload_tool(tmp_path: Path) -> None: + maturin_dir = tmp_path / "maturin" + maturin_dir.mkdir() + write_repo_fixture(maturin_dir, manifest_wheels=["ubuntu-latest"]) + plan = run_fixture_command( + maturin_dir, "build-plan", manifest=maturin_dir / "release" / "publish-artifacts.toml" + ) + assert plan.returncode == 0, plan.stderr + assert json.loads(plan.stdout)["python_upload_tool"] == "maturin" + + setuptools_dir = tmp_path / "setuptools" + setuptools_dir.mkdir() + write_repo_fixture( + setuptools_dir, manifest_wheels=["ubuntu-latest"], python_build_system="setuptools" + ) + plan = run_fixture_command( + setuptools_dir, + "build-plan", + manifest=setuptools_dir / "release" / "publish-artifacts.toml", + ) + assert plan.returncode == 0, plan.stderr + assert json.loads(plan.stdout)["python_upload_tool"] == "twine" + + rust_only_dir = tmp_path / "rust-only" + rust_only_dir.mkdir() + write_repo_fixture(rust_only_dir, manifest_wheels=["ubuntu-latest"], include_python=False) + plan = run_fixture_command( + rust_only_dir, + "build-plan", + manifest=rust_only_dir / "release" / "publish-artifacts.toml", + ) + assert plan.returncode == 0, plan.stderr + assert json.loads(plan.stdout)["python_upload_tool"] == "" + + +def test_pypi_workflows_branch_uploads_on_the_declared_build_system() -> None: + pypi_text = pypi_publish_workflow_text() + + # The uploader is manifest-derived (build-plan), not hardcoded to maturin. + assert "build-plan" in pypi_text + assert "python_upload_tool: ${{ steps.config.outputs.python_upload_tool }}" in pypi_text + assert "if: ${{ needs.verify-release.outputs.python_upload_tool == 'maturin' }}" in pypi_text + assert "if: ${{ needs.verify-release.outputs.python_upload_tool == 'twine' }}" in pypi_text + assert "python -m pip install twine==6.1.0" in pypi_text + assert "name: Publish manifest-declared wheels and sdists to TestPyPI with twine" in pypi_text + assert "name: Publish manifest-declared wheels and sdists to PyPI with twine" in pypi_text + assert ( + 'python -m twine upload --repository "${PYPI_REPOSITORY}" --skip-existing dist/*.whl dist/*.tar.gz' + in pypi_text + ) + assert "TWINE_USERNAME: __token__" in pypi_text + assert "TWINE_PASSWORD: ${{ secrets.TEST_PYPI_API_TOKEN }}" in pypi_text + assert "TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }}" in pypi_text + # No unconditional maturin install remains. + assert "\n - name: Install maturin\n run:" not in pypi_text + + # The TestPyPI rehearsal leg in release.yml takes the same manifest branch. + release_text = release_workflow_text() + assert "python_upload_tool: ${{ steps.manifest.outputs.python_upload_tool }}" in release_text + assert "if: ${{ needs.release-plan.outputs.python_upload_tool == 'maturin' }}" in release_text + assert "if: ${{ needs.release-plan.outputs.python_upload_tool == 'twine' }}" in release_text + assert "name: Publish wheels and sdist to TestPyPI with twine" in release_text + assert ( + "python -m twine upload --repository testpypi --skip-existing dist/*.whl dist/*.tar.gz" + in release_text + ) + + +def test_python_version_steps_use_the_manifest_declared_workspace_toml() -> None: + action_text = ( + repo_root() / ".github" / "actions" / "setup-python-release-build" / "action.yml" + ).read_text(encoding="utf-8") + release_text = release_workflow_text() + + assert "--workspace-toml '${{ inputs.workspace_toml }}'" in action_text + assert "--workspace-toml Cargo.toml" not in action_text + assert 'default: "Cargo.toml"' in action_text + assert "workspace_toml: ${{ steps.manifest.outputs.workspace_toml }}" in release_text + assert release_text.count( + "workspace_toml: ${{ needs.release-plan.outputs.workspace_toml }}" + ) == 2 + + +def test_version_resolution_honors_a_pyproject_workspace_toml(tmp_path: Path) -> None: + """A pure-Python consumer resolves its version without any Cargo.toml.""" + _, manifest = write_repo_fixture( + tmp_path, + manifest_wheels=["ubuntu-latest"], + include_crates=False, + python_build_system="setuptools", + ) + version_source = tmp_path / "pyproject.toml" + version_source.write_text( + '[project]\nname = "fixture-root"\nversion = "1.1.0"\n', encoding="utf-8" + ) + + verify = run_fixture_command( + tmp_path, + "verify-version", + "--workspace-toml", + str(version_source), + "--version", + "1.1.0", + manifest=manifest, + ) + assert verify.returncode == 0, verify.stderr + assert "version verification passed" in verify.stdout + + lockstep = run_fixture_command( + tmp_path, + "verify-version-lockstep", + "--workspace-toml", + str(version_source), + manifest=manifest, + ) + assert lockstep.returncode == 0, lockstep.stderr + + mismatch = run_fixture_command( + tmp_path, + "verify-version", + "--workspace-toml", + str(version_source), + "--version", + "1.2.0", + manifest=manifest, + ) + assert mismatch.returncode != 0 + assert "workspace version mismatch" in mismatch.stderr + + empty_source = tmp_path / "empty.toml" + empty_source.write_text("", encoding="utf-8") + unresolved = run_fixture_command( + tmp_path, + "verify-version", + "--workspace-toml", + str(empty_source), + "--version", + "1.1.0", + manifest=manifest, + ) + assert unresolved.returncode != 0 + assert "version source must declare" in unresolved.stderr diff --git a/.github/workflows/crates-publish.yml b/.github/workflows/crates-publish.yml new file mode 100644 index 00000000..86d7624e --- /dev/null +++ b/.github/workflows/crates-publish.yml @@ -0,0 +1,113 @@ +name: Publish Crates + +# Standalone crates.io retry leg. Production publication normally happens in +# release.yml; this workflow re-publishes only the crates still missing for an +# already-published tag, so a failed crates leg is retryable in isolation +# without re-running the release gate or rebuilding any artifact. It reads the +# ordered publish plan from release/publish-artifacts.toml and may be safely +# re-dispatched for the same tag. + +on: + workflow_dispatch: + inputs: + tag: + description: 'Published GitHub Release tag to publish (e.g. v1.4.1)' + required: true + type: string + +permissions: + contents: read + +concurrency: + group: publish-crates-${{ inputs.tag }} + cancel-in-progress: false + +jobs: + verify-release: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ inputs.tag }} + - uses: ./.github/actions/verify-published-release + with: + release_tag: ${{ inputs.tag }} + + publish-crates: + needs: verify-release + runs-on: ubuntu-latest + environment: crates-io + env: + RELEASE_ARTIFACT_MANIFEST: release/publish-artifacts.toml + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ inputs.tag }} + + - name: Read build plan from release manifest + id: build_plan + shell: bash + run: | + set -euo pipefail + plan="$(python3 .github/scripts/release_artifacts.py build-plan --manifest "${RELEASE_ARTIFACT_MANIFEST}")" + echo "rust_toolchain=$(jq -r '.rust_toolchain' <<<"${plan}")" >> "$GITHUB_OUTPUT" + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@master + with: + toolchain: ${{ steps.build_plan.outputs.rust_toolchain }} + + - name: Install Linux native build deps + uses: ./.github/actions/install-linux-native-deps + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.x" + + - name: Publish crates in order (idempotent) + env: + CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} + RELEASE_TAG: ${{ inputs.tag }} + shell: bash + run: | + set -euo pipefail + version="${RELEASE_TAG#v}" + contracts="$(dirname "${RELEASE_ARTIFACT_MANIFEST}")/publish-channel-contracts.toml" + + # Exact, contract-derived already-published lookup (200/404 state + # machine); fuzzy registry text search is index-lagged and ranked. + crate_exists() { + local crate="$1" + local version="$2" + local url status + url="$(python3 .github/scripts/release_artifacts.py public-registry-inquiry-plan \ + --contracts "${contracts}" --channel crates_io --name "${crate}" --version "${version}" \ + | jq -r '.checks[0].version_lookup_url')" + state="$(python3 .github/scripts/release_artifacts.py registry-status --url "${url}")" + case "${state}" in + published) return 0 ;; + absent) return 1 ;; + *) + echo "crates.io state for ${crate}@${version} is indeterminate (state ${state})" >&2 + exit 1 + ;; + esac + } + + publish_if_missing() { + local crate="$1" + local wait_seconds="${2:-0}" + if crate_exists "$crate" "$version"; then + echo "${crate} ${version} already published; skipping" + return 0 + fi + cargo publish -p "$crate" --locked + if [[ "$wait_seconds" != "0" ]]; then + sleep "$wait_seconds" + fi + } + + while IFS='|' read -r package wait_secs; do + publish_if_missing "$package" "${wait_secs:-0}" + done < <(python3 .github/scripts/release_artifacts.py list-publish-plan --manifest "${RELEASE_ARTIFACT_MANIFEST}") diff --git a/.github/workflows/homebrew-publish.yml b/.github/workflows/homebrew-publish.yml new file mode 100644 index 00000000..1d03e629 --- /dev/null +++ b/.github/workflows/homebrew-publish.yml @@ -0,0 +1,250 @@ +name: Publish Homebrew + +# This post-release channel reads every repository-specific value from +# release/publish-artifacts.toml and may be re-dispatched for the same tag. + +on: + workflow_dispatch: + inputs: + tag: + description: 'Published GitHub Release tag to publish (e.g. v1.4.1)' + required: true + type: string + +permissions: + contents: read + +concurrency: + group: publish-homebrew-${{ inputs.tag }} + cancel-in-progress: false + +jobs: + verify-release: + runs-on: ubuntu-latest + outputs: + channel_config: ${{ steps.config.outputs.channel_config }} + asset_patterns: ${{ steps.config.outputs.asset_patterns }} + release_version: ${{ steps.meta.outputs.release_version }} + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ inputs.tag }} + - id: config + name: Read Homebrew configuration from release manifest + shell: bash + run: | + set -euo pipefail + config="$(python3 .github/scripts/release_artifacts.py channel-config --manifest release/publish-artifacts.toml --channel homebrew --tag "${{ inputs.tag }}")" + echo "channel_config=${config}" >> "$GITHUB_OUTPUT" + { + echo 'asset_patterns<> "$GITHUB_OUTPUT" + - uses: ./.github/actions/verify-published-release + with: + release_tag: ${{ inputs.tag }} + asset_patterns: ${{ steps.config.outputs.asset_patterns }} + - id: meta + shell: bash + env: + RELEASE_TAG: ${{ inputs.tag }} + run: echo "release_version=${RELEASE_TAG#v}" >> "$GITHUB_OUTPUT" + + update-tap: + needs: verify-release + runs-on: ubuntu-latest + steps: + - name: Require Homebrew tap credential + shell: bash + env: + HOMEBREW_TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }} + run: | + set -euo pipefail + if [[ -z "${HOMEBREW_TAP_TOKEN}" ]]; then + echo 'HOMEBREW_TAP_TOKEN is required to update the configured Homebrew tap.' >&2 + exit 1 + fi + - name: Checkout workflow support + uses: actions/checkout@v4 + - name: Bootstrap renderer + uses: ./.github/actions/setup-renderer + - name: Checkout immutable release source + uses: actions/checkout@v4 + with: + ref: ${{ inputs.tag }} + path: release-source + - name: Checkout Homebrew tap + uses: actions/checkout@v4 + with: + repository: ${{ fromJSON(needs.verify-release.outputs.channel_config).channel.tap_repository }} + token: ${{ secrets.HOMEBREW_TAP_TOKEN }} + path: homebrew-tap + - name: Download configured release assets and compute SHA256 + shell: bash + env: + CHANNEL_CONFIG: ${{ needs.verify-release.outputs.channel_config }} + RELEASE_REPOSITORY: ${{ github.repository }} + RELEASE_TAG: ${{ inputs.tag }} + RELEASE_VERSION: ${{ needs.verify-release.outputs.release_version }} + run: | + set -euo pipefail + python3 - <<'PY' + import hashlib + import json + import os + from pathlib import Path + import subprocess + + config = json.loads(os.environ["CHANNEL_CONFIG"]) + project = config["project"] + assets = {} + + def download(target): + filename = f"{project['archive_prefix']}_{os.environ['RELEASE_VERSION']}_{target['target']}.{target['archive']}" + url = f"https://github.com/{os.environ['RELEASE_REPOSITORY']}/releases/download/{os.environ['RELEASE_TAG']}/{filename}" + path = Path(os.environ["RUNNER_TEMP"]) / filename + subprocess.run( + ["curl", "-fsSL", "--retry", "5", "--retry-delay", "30", "--retry-all-errors", "-o", str(path), url], + check=True, + ) + return url, path + + for asset in config["channel"]["assets"]: + target = config["release_targets"][asset["target"]] + url, path = download(target) + if asset["key"] in assets: + raise SystemExit(f"duplicate Homebrew asset key: {asset['key']}") + assets[asset["key"]] = { + "url": url, + "sha256": hashlib.file_digest(path.open("rb"), "sha256").hexdigest(), + } + required_asset_keys = {"macos_arm", "macos_intel", "linux"} + if set(assets) != required_asset_keys: + raise SystemExit( + "Homebrew assets must contain exactly: macos_arm, macos_intel, linux" + ) + Path("homebrew-release-assets.json").write_text(json.dumps(assets), encoding="utf-8") + PY + - name: Render manifest-selected formulas with the published renderer + shell: bash + env: + CHANNEL_CONFIG: ${{ needs.verify-release.outputs.channel_config }} + RELEASE_VERSION: ${{ needs.verify-release.outputs.release_version }} + run: | + set -euo pipefail + python3 - <<'PY' + import json + import os + from pathlib import Path + import subprocess + + config = json.loads(os.environ["CHANNEL_CONFIG"]) + project = config["project"] + channel = config["channel"] + assets = json.loads(Path("homebrew-release-assets.json").read_text()) + renderer = os.environ["PUBLISHED_RENDERER"] + for index, formula in enumerate(channel["formulas"]): + binaries = formula["binaries"] + bundled_paths = [] + for entry in config.get("release_binaries", []): + if entry["name"] not in binaries: + continue + for bundle in entry.get("bundled_paths", []): + destination = bundle["destination"] + bundled_paths.append( + { + "destination_components": bundle["homebrew_destination_components"], + "source_glob": f"{destination}/*", + } + ) + variables = { + "formula_class": formula["class"], + "description": project["description"], + "homepage": project["homepage"], + "license": project["license"], + "version": os.environ["RELEASE_VERSION"], + "macos_arm_url": assets["macos_arm"]["url"], + "macos_arm_sha256": assets["macos_arm"]["sha256"], + "macos_intel_url": assets["macos_intel"]["url"], + "macos_intel_sha256": assets["macos_intel"]["sha256"], + "linux_url": assets["linux"]["url"], + "linux_sha256": assets["linux"]["sha256"], + "test_binary": formula["test_binary"], + "test_command": formula["test_command"], + "test_output": formula["test_output"], + "binary_paths": [f"bin/{binary}" for binary in binaries], + "bundled_paths": bundled_paths, + } + variables_path = Path(f"homebrew-vars-{index}.json") + variables_path.write_text(json.dumps(variables), encoding="utf-8") + output = Path("homebrew-tap") / formula["path"] + output.parent.mkdir(parents=True, exist_ok=True) + subprocess.run( + [ + renderer, + "render", + "--mode", + "file", + "--root", + "release-source", + "--file", + formula["template"], + "--var-file", + str(variables_path.resolve()), + "--output", + str(output.resolve()), + ], + check=True, + ) + PY + - name: Verify formula syntax + shell: bash + env: + CHANNEL_CONFIG: ${{ needs.verify-release.outputs.channel_config }} + run: | + set -euo pipefail + python3 - <<'PY' + import json + import os + import subprocess + from pathlib import Path + + for formula in json.loads(os.environ["CHANNEL_CONFIG"])["channel"]["formulas"]: + subprocess.run(["ruby", "-c", str(Path("homebrew-tap") / formula["path"])], check=True) + PY + - name: Commit and push formula updates + shell: bash + env: + CHANNEL_CONFIG: ${{ needs.verify-release.outputs.channel_config }} + PROJECT_NAME: ${{ fromJSON(needs.verify-release.outputs.channel_config).project.name }} + RELEASE_VERSION: ${{ needs.verify-release.outputs.release_version }} + run: | + set -euo pipefail + python3 - <<'PY' > homebrew-formula-paths.txt + import json + import os + + for formula in json.loads(os.environ["CHANNEL_CONFIG"])["channel"]["formulas"]: + print(formula["path"]) + PY + cd homebrew-tap + git config user.name 'github-actions[bot]' + git config user.email 'github-actions[bot]@users.noreply.github.com' + while IFS= read -r formula_path; do + git add -- "${formula_path}" + done < ../homebrew-formula-paths.txt + git diff --cached --quiet && echo 'Formula already matches the release assets' && exit 0 + git commit -m "chore: bump ${PROJECT_NAME} to v${RELEASE_VERSION}" + # Concurrency groups are per-tag, so two different tags can race the + # tap push; fetch-rebase-retry instead of failing on the first reject. + tap_branch="$(git rev-parse --abbrev-ref HEAD)" + for attempt in 1 2 3 4 5; do + if git push origin "${tap_branch}"; then + exit 0 + fi + echo "push rejected (attempt ${attempt}); rebasing onto origin/${tap_branch}" + git pull --rebase origin "${tap_branch}" + done + echo "failed to push Homebrew formula update after 5 attempts" >&2 + exit 1 diff --git a/.github/workflows/pypi-publish.yml b/.github/workflows/pypi-publish.yml new file mode 100644 index 00000000..ab155d0e --- /dev/null +++ b/.github/workflows/pypi-publish.yml @@ -0,0 +1,131 @@ +name: Publish PyPI + +# Production publication is intentionally separate from release.yml. It uploads +# only the already-attached artifacts from a published, immutable GitHub Release +# so an upload failure can be retried without creating another tag, rerunning +# crates.io publication, or rebuilding any artifact. + +on: + workflow_dispatch: + inputs: + tag: + description: 'Published GitHub Release tag to publish (e.g. v1.4.1)' + required: true + type: string + target: + description: 'Python package repository' + required: true + default: testpypi + type: choice + options: + - testpypi + - production + +permissions: + contents: read + +concurrency: + group: publish-pypi-${{ inputs.target }}-${{ inputs.tag }} + cancel-in-progress: false + +jobs: + verify-release: + runs-on: ubuntu-latest + outputs: + pypi_config: ${{ steps.config.outputs.config }} + python_upload_tool: ${{ steps.config.outputs.python_upload_tool }} + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ inputs.tag }} + - uses: ./.github/actions/verify-published-release + with: + release_tag: ${{ inputs.tag }} + - id: config + name: Read PyPI channel configuration from release manifest + shell: bash + run: | + set -euo pipefail + echo "config=$(python3 .github/scripts/release_artifacts.py channel-config --manifest release/publish-artifacts.toml --channel pypi)" >> "$GITHUB_OUTPUT" + # The manifest's declared build systems select the uploader: + # maturin when any distribution is maturin-built, twine otherwise. + plan="$(python3 .github/scripts/release_artifacts.py build-plan --manifest release/publish-artifacts.toml)" + echo "python_upload_tool=$(jq -r '.python_upload_tool' <<<"${plan}")" >> "$GITHUB_OUTPUT" + + publish: + needs: verify-release + runs-on: ubuntu-latest + environment: ${{ inputs.target == 'production' && 'pypi' || 'testpypi' }} + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ inputs.tag }} + - name: Download Python assets from the published GitHub Release + shell: bash + env: + GH_TOKEN: ${{ github.token }} + RELEASE_REPOSITORY: ${{ github.repository }} + RELEASE_TAG: ${{ inputs.tag }} + run: | + set -euo pipefail + mkdir -p release-assets dist + gh release download "${RELEASE_TAG}" --repo "${RELEASE_REPOSITORY}" \ + --pattern '*.whl' --pattern '*.tar.gz' --dir release-assets + - name: Select and verify Python distributions + shell: bash + run: | + set -euo pipefail + python3 .github/scripts/release_artifacts.py verify-python-release-assets \ + --manifest release/publish-artifacts.toml \ + --asset-dir release-assets \ + --copy-to dist + - name: Select configured Python repository + shell: bash + env: + CHANNEL_CONFIG: ${{ needs.verify-release.outputs.pypi_config }} + PUBLISH_TARGET: ${{ inputs.target }} + run: | + set -euo pipefail + python3 - <<'PY' >> "$GITHUB_ENV" + import json + import os + + config = json.loads(os.environ["CHANNEL_CONFIG"])["channel"] + key = "test_repository" if os.environ["PUBLISH_TARGET"] == "testpypi" else "production_repository" + print(f"PYPI_REPOSITORY={config[key]}") + PY + - uses: actions/setup-python@v5 + with: + python-version: '3.11' + - name: Install maturin + if: ${{ needs.verify-release.outputs.python_upload_tool == 'maturin' }} + run: python -m pip install maturin==1.9.4 + - name: Install twine + if: ${{ needs.verify-release.outputs.python_upload_tool == 'twine' }} + run: python -m pip install twine==6.1.0 + - name: Publish manifest-declared wheels and sdists to TestPyPI + if: ${{ inputs.target == 'testpypi' && needs.verify-release.outputs.python_upload_tool == 'maturin' }} + env: + MATURIN_NON_INTERACTIVE: '1' + MATURIN_PYPI_TOKEN: ${{ secrets.TEST_PYPI_API_TOKEN }} + run: maturin upload --repository "${PYPI_REPOSITORY}" --non-interactive --skip-existing dist/*.whl dist/*.tar.gz + - name: Publish manifest-declared wheels and sdists to PyPI + if: ${{ inputs.target == 'production' && needs.verify-release.outputs.python_upload_tool == 'maturin' }} + env: + MATURIN_NON_INTERACTIVE: '1' + MATURIN_PYPI_TOKEN: ${{ secrets.PYPI_API_TOKEN }} + run: maturin upload --repository "${PYPI_REPOSITORY}" --non-interactive --skip-existing dist/*.whl dist/*.tar.gz + - name: Publish manifest-declared wheels and sdists to TestPyPI with twine + if: ${{ inputs.target == 'testpypi' && needs.verify-release.outputs.python_upload_tool == 'twine' }} + env: + TWINE_NON_INTERACTIVE: '1' + TWINE_USERNAME: __token__ + TWINE_PASSWORD: ${{ secrets.TEST_PYPI_API_TOKEN }} + run: python -m twine upload --repository "${PYPI_REPOSITORY}" --skip-existing dist/*.whl dist/*.tar.gz + - name: Publish manifest-declared wheels and sdists to PyPI with twine + if: ${{ inputs.target == 'production' && needs.verify-release.outputs.python_upload_tool == 'twine' }} + env: + TWINE_NON_INTERACTIVE: '1' + TWINE_USERNAME: __token__ + TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }} + run: python -m twine upload --repository "${PYPI_REPOSITORY}" --skip-existing dist/*.whl dist/*.tar.gz diff --git a/.github/workflows/release-candidate.yml b/.github/workflows/release-candidate.yml new file mode 100644 index 00000000..ef0bd49e --- /dev/null +++ b/.github/workflows/release-candidate.yml @@ -0,0 +1,67 @@ +name: Release Candidate + +on: + workflow_dispatch: + inputs: + version: + description: "Release version (e.g. 1.0.0 or v1.0.0)" + required: true + type: string + run_by_agent: + description: "Must be 'publisher' (agent ownership assertion)" + required: true + default: "publisher" + type: string + +permissions: + contents: write + +jobs: + establish-provenance: + runs-on: ubuntu-latest + outputs: + release_candidate_tag: ${{ steps.candidate.outputs.tag }} + release_candidate_sha: ${{ steps.candidate.outputs.sha }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Enforce publisher ownership input + shell: bash + run: | + set -euo pipefail + [[ '${{ github.event.inputs.run_by_agent }}' == 'publisher' ]] \ + || { echo "Release candidate must be created by publisher." >&2; exit 1; } + + - id: candidate + name: Reuse or create release-candidate tag from develop + shell: bash + run: | + set -euo pipefail + raw='${{ github.event.inputs.version }}' + release_tag="$raw" + [[ "$release_tag" == v* ]] || release_tag="v${release_tag}" + [[ "$release_tag" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]] \ + || { echo "Invalid version: '$raw' (expected X.Y.Z or vX.Y.Z)" >&2; exit 1; } + candidate_tag="release-candidate-${release_tag}" + + git fetch origin develop --prune --tags + develop_sha="$(git rev-parse origin/develop)" + if git rev-parse --verify "refs/tags/${candidate_tag}" >/dev/null 2>&1; then + candidate_sha="$(git rev-parse "${candidate_tag}^{commit}")" + git merge-base --is-ancestor "${candidate_tag}" origin/develop \ + || { echo "${candidate_tag} is not an ancestor of origin/develop" >&2; exit 1; } + echo "Reusing ${candidate_tag} at ${candidate_sha}; origin/develop is ${develop_sha}." + else + git -c user.name="github-actions[bot]" \ + -c user.email="41898282+github-actions[bot]@users.noreply.github.com" \ + tag -a "${candidate_tag}" origin/develop -m "Release candidate ${release_tag}" + git push origin "refs/tags/${candidate_tag}" + candidate_sha="${develop_sha}" + echo "Created ${candidate_tag} at origin/develop (${candidate_sha})." + fi + { + echo "tag=${candidate_tag}" + echo "sha=${candidate_sha}" + } >> "$GITHUB_OUTPUT" diff --git a/.github/workflows/release-preflight.yml b/.github/workflows/release-preflight.yml index 4b635c85..f2d780bb 100644 --- a/.github/workflows/release-preflight.yml +++ b/.github/workflows/release-preflight.yml @@ -12,18 +12,32 @@ on: required: true default: "publisher" type: string + already_published_channels: + description: "Optional comma-separated channel names preserved from a prior release run for this exact version" + required: false + default: "" + type: string -permissions: - contents: read - -env: - RELEASE_ARTIFACT_MANIFEST: release/publish-artifacts.toml +# `environments` is not an individual workflow-permission key. `read-all` is +# GitHub's valid read-only permission shorthand; environment-secret metadata +# still remains unavailable to the installation token and is informational. +permissions: read-all jobs: preflight: runs-on: ubuntu-latest + outputs: + channel_preflight_results: ${{ steps.channel_results.outputs.channel_preflight_results }} + env: + RELEASE_ARTIFACT_MANIFEST: release/publish-artifacts.toml steps: - - name: Enforce publisher ownership input + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - id: ownership + name: Enforce publisher ownership input + continue-on-error: true shell: bash run: | set -euo pipefail @@ -33,37 +47,178 @@ jobs: exit 1 fi - - uses: actions/checkout@v4 - with: - fetch-depth: 0 + - id: secret_plan + name: Read non-disclosing credential plan from release manifest + continue-on-error: true + shell: bash + run: | + set -euo pipefail + echo "plan=$(python3 .github/scripts/release_artifacts.py preflight-secret-plan --manifest "${RELEASE_ARTIFACT_MANIFEST}")" >> "$GITHUB_OUTPUT" - - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@stable - with: - components: clippy, rustfmt - - # rfd (xdg-portal/ashpd) needs wayland-client.pc; wry needs webkit2gtk (c.15 viewer). - - name: Install Linux build deps - run: | - sudo apt-get update - sudo apt-get install -y \ - libwayland-dev \ - pkg-config \ - libwebkit2gtk-4.1-dev \ - libxkbcommon-x11-0 \ - libgl1-mesa-dri \ - mesa-utils \ - at-spi2-core \ - dbus-x11 \ - xvfb - - - name: Install Python + - id: build_plan + name: Read build plan from release manifest + continue-on-error: true + shell: bash + run: | + set -euo pipefail + plan="$(python3 .github/scripts/release_artifacts.py build-plan --manifest "${RELEASE_ARTIFACT_MANIFEST}")" + { + echo "has_crates=$(jq -r '.has_crates' <<<"${plan}")" + echo "workspace_toml=$(jq -r '.workspace_toml' <<<"${plan}")" + echo "rust_toolchain=$(jq -r '.rust_toolchain' <<<"${plan}")" + } >> "$GITHUB_OUTPUT" + + - id: repository_secrets + name: Verify required release secrets are available + if: ${{ always() && steps.secret_plan.outcome == 'success' }} + continue-on-error: true + shell: bash + env: + SECRET_PLAN: ${{ steps.secret_plan.outputs.plan }} + CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} + HOMEBREW_TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }} + SCOOP_BUCKET_TOKEN: ${{ secrets.SCOOP_BUCKET_TOKEN }} + WINGET_GITHUB_TOKEN: ${{ secrets.WINGET_GITHUB_TOKEN }} + run: | + set -euo pipefail + missing=() + channel_outcomes='{}' + while IFS=$'\t' read -r channel secret_names; do + channel_status=success + while IFS= read -r secret_name; do + if [[ -z "${!secret_name:-}" ]]; then + missing+=("${channel}:${secret_name}") + channel_status=failure + fi + done < <(tr ',' '\n' <<<"${secret_names}") + channel_outcomes="$(jq -c --arg channel "${channel}" --arg status "${channel_status}" '.[$channel]=$status' <<<"${channel_outcomes}")" + done < <(jq -r '.repository_secret_channels[] | [.name, (.secrets | join(","))] | @tsv' <<<"${SECRET_PLAN}") + { + echo "channel_outcomes=${channel_outcomes}" + } >> "${GITHUB_OUTPUT}" + if [[ "${#missing[@]}" -gt 0 ]]; then + echo "Missing required GitHub Actions release secret(s): ${missing[*]}" >&2 + exit 1 + fi + echo "All manifest-required repository secrets are available." + + - id: environment_secrets + name: Inspect protected Python environment secret metadata (informational) + if: ${{ always() && steps.secret_plan.outcome == 'success' }} + continue-on-error: true + shell: bash + env: + GH_TOKEN: ${{ github.token }} + REPOSITORY: ${{ github.repository }} + SECRET_PLAN: ${{ steps.secret_plan.outputs.plan }} + run: | + set -euo pipefail + failures=() + unavailable=() + while IFS=$'\t' read -r environment_name secret_name; do + if ! names="$(gh api "repos/${REPOSITORY}/environments/${environment_name}/secrets" --jq '.secrets[].name')"; then + unavailable+=("${environment_name}:${secret_name}") + elif ! grep -Fxq -- "${secret_name}" <<<"${names}"; then + failures+=("GitHub Actions environment ${environment_name} is missing required secret ${secret_name}.") + fi + done < <(jq -r '.environment_secrets[] | [.environment, .name] | @tsv' <<<"${SECRET_PLAN}") + if [[ "${#failures[@]}" -gt 0 ]]; then + printf '%s\n' "${failures[@]}" >&2 + exit 1 + fi + if [[ "${#unavailable[@]}" -gt 0 ]]; then + printf 'Environment-secret metadata is unavailable to GITHUB_TOKEN for: %s. This is informational; protected environments enforce the configured secrets when their publish jobs run.\n' "${unavailable[*]}" + else + echo "All manifest-required protected-environment secret names are present." + fi + + - id: github_environments + name: Verify contract-declared GitHub environments exist + if: ${{ always() && steps.secret_plan.outcome == 'success' }} + continue-on-error: true + shell: bash + env: + GH_TOKEN: ${{ github.token }} + REPOSITORY: ${{ github.repository }} + SECRET_PLAN: ${{ steps.secret_plan.outputs.plan }} + run: | + set -euo pipefail + failures=() + while IFS= read -r environment_name; do + [[ -z "${environment_name}" ]] && continue + if ! gh api "repos/${REPOSITORY}/environments/${environment_name}" >/dev/null 2>&1; then + failures+=("GitHub Actions environment ${environment_name} is declared by the channel contracts but does not exist.") + fi + done < <(jq -r '.github_environments[]?' <<<"${SECRET_PLAN}") + if [[ "${#failures[@]}" -gt 0 ]]; then + printf '%s\n' "${failures[@]}" >&2 + exit 1 + fi + echo "All contract-declared GitHub environments exist." + + - id: credential_liveness + name: Verify repository credential liveness + if: ${{ always() && steps.secret_plan.outcome == 'success' }} + continue-on-error: true + shell: bash + env: + SECRET_PLAN: ${{ steps.secret_plan.outputs.plan }} + HOMEBREW_TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }} + SCOOP_BUCKET_TOKEN: ${{ secrets.SCOOP_BUCKET_TOKEN }} + WINGET_GITHUB_TOKEN: ${{ secrets.WINGET_GITHUB_TOKEN }} + run: | + set -euo pipefail + failures=() + channel_outcomes='{}' + while IFS=$'\t' read -r channel secret_name check_kind; do + token="${!secret_name:-}" + channel_status=success + case "${check_kind}" in + github) + command=(curl --fail --silent --show-error --header "Authorization: Bearer ${token}" --header "Accept: application/vnd.github+json" https://api.github.com/user) + ;; + *) + failures+=("Unsupported credential liveness check kind: ${check_kind}") + channel_status=failure + continue + ;; + esac + if ! "${command[@]}" >/dev/null; then + failures+=("GitHub Actions secret ${secret_name} is present but rejected by its publish service.") + channel_status=failure + fi + channel_outcomes="$(jq -c --arg channel "${channel}" --arg status "${channel_status}" '.[$channel]=$status' <<<"${channel_outcomes}")" + done < <(jq -r '.liveness_channel_checks[] | [.channel, .name, .kind] | @tsv' <<<"${SECRET_PLAN}") + echo "channel_outcomes=${channel_outcomes}" >> "${GITHUB_OUTPUT}" + if [[ "${#failures[@]}" -gt 0 ]]; then + printf '%s\n' "${failures[@]}" >&2 + exit 1 + fi + echo "All manifest-required repository credentials are accepted by their publish services." + + - name: Set up Python uses: actions/setup-python@v5 with: python-version: "3.x" + - name: Set up lint toolchain + if: ${{ always() && steps.build_plan.outputs.has_crates == 'true' }} + uses: ./.github/actions/setup-lint-toolchain + + - name: Install Rust toolchain + if: ${{ always() && steps.build_plan.outputs.has_crates == 'true' }} + uses: dtolnay/rust-toolchain@master + with: + toolchain: ${{ steps.build_plan.outputs.rust_toolchain }} + components: rustfmt, clippy + + - name: Install Linux native build deps + if: ${{ always() && steps.build_plan.outputs.has_crates == 'true' }} + uses: ./.github/actions/install-linux-native-deps + - name: Normalize version input id: meta + continue-on-error: true shell: bash run: | set -euo pipefail @@ -74,30 +229,287 @@ jobs: echo "Invalid version: '$raw' (expected X.Y.Z or vX.Y.Z)" >&2 exit 1 fi - version="${tag#v}" echo "release_tag=$tag" >> "$GITHUB_OUTPUT" - echo "release_version=$version" >> "$GITHUB_OUTPUT" + echo "release_version=${tag#v}" >> "$GITHUB_OUTPUT" + + - id: formatting + name: Check formatting + if: ${{ always() && steps.build_plan.outputs.has_crates == 'true' }} + continue-on-error: true + run: cargo fmt --all --check + + - id: clippy + name: Run clippy + if: ${{ always() && steps.build_plan.outputs.has_crates == 'true' }} + continue-on-error: true + run: cargo clippy --all-targets --all-features -- -D warnings + + - id: workspace_tests + name: Run workspace tests + if: ${{ always() && steps.build_plan.outputs.has_crates == 'true' }} + continue-on-error: true + run: cargo test --workspace + + - id: manifest + name: Validate manifest completeness + continue-on-error: true + run: | + python3 .github/scripts/release_artifacts.py validate-manifest \ + --manifest "${RELEASE_ARTIFACT_MANIFEST}" \ + --workspace-toml '${{ steps.build_plan.outputs.workspace_toml }}' + + - id: publish_order + name: Validate publish_order dependency graph + continue-on-error: true + run: | + python3 .github/scripts/release_artifacts.py validate-publish-order \ + --manifest "${RELEASE_ARTIFACT_MANIFEST}" \ + --workspace-toml '${{ steps.build_plan.outputs.workspace_toml }}' + + - id: helpers + name: Verify release helper files exist + continue-on-error: true + run: | + test -f .github/scripts/release_gate.sh + + - id: version + name: Verify requested version matches workspace + if: ${{ always() && steps.meta.outcome == 'success' }} + continue-on-error: true + run: | + python3 .github/scripts/release_artifacts.py verify-version \ + --manifest "${RELEASE_ARTIFACT_MANIFEST}" \ + --workspace-toml '${{ steps.build_plan.outputs.workspace_toml }}' \ + --version '${{ steps.meta.outputs.release_version }}' + + - id: version_lockstep + name: Verify release version lockstep + continue-on-error: true + run: | + python3 .github/scripts/release_artifacts.py verify-version-lockstep \ + --manifest "${RELEASE_ARTIFACT_MANIFEST}" \ + --workspace-toml '${{ steps.build_plan.outputs.workspace_toml }}' + + - id: release_candidate_provenance + name: Verify release-candidate provenance + if: ${{ always() && steps.meta.outcome == 'success' }} + continue-on-error: true + env: + ALREADY_PUBLISHED_CHANNELS: ${{ inputs.already_published_channels }} + run: | + .github/scripts/release_gate.sh readiness HEAD "release-candidate-${{ steps.meta.outputs.release_tag }}" \ + '${{ steps.meta.outputs.release_version }}' "${RELEASE_ARTIFACT_MANIFEST}" \ + '${{ steps.build_plan.outputs.workspace_toml }}' "${ALREADY_PUBLISHED_CHANNELS}" + + - id: unpublished + name: Verify release version is unpublished on crates.io + if: ${{ always() && steps.meta.outcome == 'success' }} + continue-on-error: true + shell: bash + env: + ALREADY_PUBLISHED_CHANNELS: ${{ inputs.already_published_channels }} + run: | + python3 .github/scripts/release_artifacts.py check-version-unpublished \ + --manifest "${RELEASE_ARTIFACT_MANIFEST}" \ + --version '${{ steps.meta.outputs.release_version }}' \ + --already-published-channels "${ALREADY_PUBLISHED_CHANNELS}" - - name: Run canonical retained release validation suite - id: validate + - id: registry_state + name: Verify registry versions and new names + if: ${{ always() && steps.meta.outcome == 'success' }} continue-on-error: true shell: bash + env: + ALREADY_PUBLISHED_CHANNELS: ${{ inputs.already_published_channels }} run: | set -euo pipefail - # setup-python pins PKG_CONFIG_PATH to its lib; restore system paths for cargo/wry. - export PKG_CONFIG_PATH="/usr/lib/x86_64-linux-gnu/pkgconfig:/usr/share/pkgconfig${PKG_CONFIG_PATH:+:$PKG_CONFIG_PATH}" - python3 scripts/validate_release.py all \ + failures=() + channel_is_preserved() { + local expected="$1" channel + IFS=',' read -r -a channels <<<"${ALREADY_PUBLISHED_CHANNELS:-}" + for channel in "${channels[@]}"; do + channel="$(tr -d '[:space:]' <<<"${channel}")" + [[ "${channel}" == "${expected}" ]] && return 0 + done + return 1 + } + while IFS=$'\t' read -r channel agent registry name normalized_name expected_version project_url version_url version_policy; do + project_state="$(python3 .github/scripts/release_artifacts.py registry-status --url "${project_url}")" + version_state="$(python3 .github/scripts/release_artifacts.py registry-status --url "${version_url}")" + case "${project_state}:${version_state}:${version_policy}" in + absent:absent:*) + printf '%s: %s name %s is apparently available (not a reservation).\n' \ + "${agent}" "${registry}" "${normalized_name}" + ;; + published:absent:*) + printf '%s: %s project %s exists; version %s is not published.\n' \ + "${agent}" "${registry}" "${normalized_name}" "${expected_version}" + ;; + published:published:must_be_absent) + if channel_is_preserved "${channel}"; then + printf '%s: %s version %s@%s is preserved from a prior release run.\n' \ + "${agent}" "${registry}" "${normalized_name}" "${expected_version}" + else + failures+=("${channel}/${registry}: ${normalized_name}@${expected_version} is already published.") + fi + ;; + published:published:informational) + printf '%s: %s rehearsal version %s@%s already exists (informational).\n' \ + "${agent}" "${registry}" "${normalized_name}" "${expected_version}" + ;; + *) + failures+=("${channel}/${registry}: registry state for ${normalized_name}@${expected_version} is indeterminate (project=${project_state}, version=${version_state}).") + ;; + esac + done < <(python3 .github/scripts/release_artifacts.py public-registry-check-plan \ + --manifest "${RELEASE_ARTIFACT_MANIFEST}" \ --version '${{ steps.meta.outputs.release_version }}' \ - --findings release-findings.json + | jq -r '.checks[] | [.channel, .agent, .registry, .name, .normalized_name, .expected_version, .project_lookup_url, .version_lookup_url, .version_policy] | @tsv') - - name: Upload release findings - if: always() - uses: actions/upload-artifact@v4 - with: - name: release-findings - path: release-findings.json + if [[ "${#failures[@]}" -gt 0 ]]; then + printf '%s\n' "${failures[@]}" >&2 + exit 1 + fi + + - id: package_checks + name: Run dependency-aware package checks + if: ${{ always() && steps.build_plan.outputs.has_crates == 'true' }} + continue-on-error: true + shell: bash + run: | + set -euo pipefail + while IFS='|' read -r package _; do + cargo package -p "$package" --locked --allow-dirty + done < <(python3 .github/scripts/release_artifacts.py list-publish-plan --manifest "${RELEASE_ARTIFACT_MANIFEST}") - - name: Fail if canonical validation failed - if: steps.validate.outcome != 'success' + - id: github_release_permissions + name: Verify GitHub Release workflow permissions + continue-on-error: true shell: bash - run: exit 1 + run: | + set -euo pipefail + python3 - <<'PY' + from pathlib import Path + + workflow = Path(".github/workflows/release.yml").read_text(encoding="utf-8") + if "contents: write" not in workflow: + raise SystemExit("GitHub Release workflow must declare contents: write") + PY + + - id: channel_results + name: Emit manifest-derived per-channel preflight results + if: ${{ always() }} + continue-on-error: true + shell: bash + env: + OWNERSHIP: ${{ steps.ownership.outcome }} + RELEASE_METADATA: ${{ steps.meta.outcome }} + RELEASE_TAG: ${{ steps.meta.outputs.release_tag }} + REPOSITORY_SECRETS: ${{ steps.repository_secrets.outcome }} + REPOSITORY_SECRET_CHANNELS: ${{ steps.repository_secrets.outputs.channel_outcomes }} + ENVIRONMENT_SECRETS: ${{ steps.environment_secrets.outcome }} + CREDENTIAL_LIVENESS: ${{ steps.credential_liveness.outcome }} + CREDENTIAL_LIVENESS_CHANNELS: ${{ steps.credential_liveness.outputs.channel_outcomes }} + REGISTRY_STATE: ${{ steps.registry_state.outcome }} + GITHUB_RELEASE_PERMISSIONS: ${{ steps.github_release_permissions.outcome }} + run: | + set -euo pipefail + repository_secret_channels_json="${REPOSITORY_SECRET_CHANNELS:-}" + credential_liveness_channels_json="${CREDENTIAL_LIVENESS_CHANNELS:-}" + [[ -n "${repository_secret_channels_json}" ]] || repository_secret_channels_json='{}' + [[ -n "${credential_liveness_channels_json}" ]] || credential_liveness_channels_json='{}' + jq -e 'type == "object"' <<<"${repository_secret_channels_json}" >/dev/null \ + || { echo 'REPOSITORY_SECRET_CHANNELS must be a JSON object.' >&2; exit 1; } + jq -e 'type == "object"' <<<"${credential_liveness_channels_json}" >/dev/null \ + || { echo 'CREDENTIAL_LIVENESS_CHANNELS must be a JSON object.' >&2; exit 1; } + outcomes="$(jq -nc \ + --arg ownership "${OWNERSHIP}" \ + --arg release_metadata "${RELEASE_METADATA}" \ + --arg repository_secrets "${REPOSITORY_SECRETS}" \ + --argjson repository_secret_channels "${repository_secret_channels_json}" \ + --arg environment_secrets "${ENVIRONMENT_SECRETS}" \ + --arg credential_liveness "${CREDENTIAL_LIVENESS}" \ + --argjson credential_liveness_channels "${credential_liveness_channels_json}" \ + --arg registry_state "${REGISTRY_STATE}" \ + --arg github_release_permissions "${GITHUB_RELEASE_PERMISSIONS}" \ + '{ownership: $ownership, release_metadata: $release_metadata, repository_secrets: $repository_secrets, repository_secret_channels: $repository_secret_channels, environment_secrets: $environment_secrets, credential_liveness: $credential_liveness, credential_liveness_channels: $credential_liveness_channels, registry_state: $registry_state, github_release_permissions: $github_release_permissions}')" + results="$(python3 .github/scripts/release_artifacts.py channel-preflight-results \ + --manifest "${RELEASE_ARTIFACT_MANIFEST}" \ + --outcomes "${outcomes}" \ + --tag "${RELEASE_TAG}")" + { + echo 'channel_preflight_results<> "$GITHUB_OUTPUT" + printf '%s\n' "${results}" >> "$GITHUB_STEP_SUMMARY" + + - name: Deny release after complete preflight summary + if: ${{ always() }} + shell: bash + env: + OWNERSHIP: ${{ steps.ownership.outcome }} + SECRET_PLAN: ${{ steps.secret_plan.outcome }} + BUILD_PLAN: ${{ steps.build_plan.outcome }} + HAS_CRATES: ${{ steps.build_plan.outputs.has_crates }} + REPOSITORY_SECRETS: ${{ steps.repository_secrets.outcome }} + ENVIRONMENT_SECRETS: ${{ steps.environment_secrets.outcome }} + GITHUB_ENVIRONMENTS: ${{ steps.github_environments.outcome }} + CREDENTIAL_LIVENESS: ${{ steps.credential_liveness.outcome }} + REGISTRY_STATE: ${{ steps.registry_state.outcome }} + META: ${{ steps.meta.outcome }} + FORMATTING: ${{ steps.formatting.outcome }} + CLIPPY: ${{ steps.clippy.outcome }} + WORKSPACE_TESTS: ${{ steps.workspace_tests.outcome }} + MANIFEST: ${{ steps.manifest.outcome }} + PUBLISH_ORDER: ${{ steps.publish_order.outcome }} + HELPERS: ${{ steps.helpers.outcome }} + VERSION: ${{ steps.version.outcome }} + VERSION_LOCKSTEP: ${{ steps.version_lockstep.outcome }} + RELEASE_CANDIDATE_PROVENANCE: ${{ steps.release_candidate_provenance.outcome }} + UNPUBLISHED: ${{ steps.unpublished.outcome }} + PACKAGE_CHECKS: ${{ steps.package_checks.outcome }} + GITHUB_RELEASE_PERMISSIONS: ${{ steps.github_release_permissions.outcome }} + CHANNEL_RESULTS: ${{ steps.channel_results.outcome }} + run: | + set -euo pipefail + failed=() + blocked=() + record() { + local name="$1" outcome="$2" + case "${outcome}" in + success) ;; + skipped) blocked+=("${name}") ;; + *) failed+=("${name}") ;; + esac + } + record ownership "${OWNERSHIP}" + record secret-plan "${SECRET_PLAN}" + record build-plan "${BUILD_PLAN}" + record repository-secrets "${REPOSITORY_SECRETS}" + record environment-secrets "${ENVIRONMENT_SECRETS}" + record github-environments "${GITHUB_ENVIRONMENTS}" + record credential-liveness "${CREDENTIAL_LIVENESS}" + record registry-state "${REGISTRY_STATE}" + record version-input "${META}" + # Cargo checks only apply when the manifest declares [[crates]]. + if [[ "${HAS_CRATES}" == "true" ]]; then + record formatting "${FORMATTING}" + record clippy "${CLIPPY}" + record workspace-tests "${WORKSPACE_TESTS}" + record package-checks "${PACKAGE_CHECKS}" + fi + record manifest "${MANIFEST}" + record publish-order "${PUBLISH_ORDER}" + record helpers "${HELPERS}" + record version "${VERSION}" + record version-lockstep "${VERSION_LOCKSTEP}" + record release-candidate-provenance "${RELEASE_CANDIDATE_PROVENANCE}" + record unpublished-version "${UNPUBLISHED}" + record github-release-permissions "${GITHUB_RELEASE_PERMISSIONS}" + record channel-results "${CHANNEL_RESULTS}" + printf 'Preflight complete: failed=[%s] blocked=[%s]\n' \ + "$(IFS=,; echo "${failed[*]}")" "$(IFS=,; echo "${blocked[*]}")" + if [[ "${#failed[@]}" -gt 0 || "${#blocked[@]}" -gt 0 ]]; then + exit 1 + fi diff --git a/.github/workflows/release-retry-distribution.yml b/.github/workflows/release-retry-distribution.yml deleted file mode 100644 index 748e8ed6..00000000 --- a/.github/workflows/release-retry-distribution.yml +++ /dev/null @@ -1,141 +0,0 @@ -name: Release Retry Distribution - -on: - workflow_dispatch: - inputs: - tag: - description: "Release tag (e.g. v0.1.0)" - required: true - default: "v0.1.0" - type: string - -permissions: - contents: read - -env: - RELEASE_ARTIFACT_MANIFEST: release/publish-artifacts.toml - -jobs: - publish-crates: - name: Publish crates.io - runs-on: ubuntu-latest - environment: crates-io - steps: - - uses: actions/checkout@v4 - with: - ref: ${{ inputs.tag }} - - - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@stable - - - name: Install Linux build deps - run: | - sudo apt-get update - sudo apt-get install -y \ - libwayland-dev \ - pkg-config \ - libwebkit2gtk-4.1-dev \ - libxkbcommon-x11-0 \ - libgl1-mesa-dri \ - mesa-utils \ - at-spi2-core \ - dbus-x11 \ - xvfb - - - name: Publish crates in dependency order (idempotent) - shell: bash - env: - CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} - PKG_CONFIG_PATH: /usr/lib/x86_64-linux-gnu/pkgconfig:/usr/share/pkgconfig - run: | - set -euo pipefail - tag='${{ inputs.tag }}' - version="${tag#v}" - - crate_exists() { - local crate="$1" - curl -fsS "https://crates.io/api/v1/crates/${crate}/${version}" >/dev/null 2>&1 - } - - publish_if_missing() { - local crate="$1" - local wait_seconds="${2:-0}" - if crate_exists "$crate"; then - echo "${crate} ${version} already published; skipping" - return 0 - fi - set +e - output=$(cargo publish -p "$crate" --locked 2>&1) - status=$? - set -e - if [[ "$status" -eq 0 ]]; then - echo "$output" - elif echo "$output" | grep -q 'already exists on crates.io index'; then - echo "${crate} ${version} already published; skipping" - else - echo "$output" >&2 - exit "$status" - fi - if [[ "$wait_seconds" -gt 0 ]]; then - sleep "$wait_seconds" - fi - } - - while IFS='|' read -r crate wait_seconds; do - [[ -n "$crate" ]] || continue - publish_if_missing "$crate" "${wait_seconds:-0}" - done < <( - python3 scripts/release_artifacts.py list-publish-plan \ - --manifest "${RELEASE_ARTIFACT_MANIFEST}" - ) - - update-homebrew: - name: Update Homebrew tap - runs-on: ubuntu-latest - steps: - - name: Checkout homebrew-tap - uses: actions/checkout@v4 - with: - repository: randlee/homebrew-tap - token: ${{ secrets.HOMEBREW_TAP_TOKEN }} - path: homebrew-tap - - - name: Compute release tarball SHA256 - id: sha - shell: bash - run: | - set -euo pipefail - tag='${{ inputs.tag }}' - tarball_path="${RUNNER_TEMP:?RUNNER_TEMP is required}/release.tar.gz" - tarball_url="https://github.com/randlee/wyvern/releases/download/${tag}/wyvern-macos-aarch64.tar.gz" - echo "tarball_url=${tarball_url}" >> "$GITHUB_OUTPUT" - curl -fsSL --retry 5 --retry-delay 30 --retry-all-errors -o "${tarball_path}" "${tarball_url}" - sha256=$(sha256sum "${tarball_path}" | awk '{print $1}') - echo "sha256=${sha256}" >> "$GITHUB_OUTPUT" - - - name: Update Homebrew formula - shell: bash - run: | - set -euo pipefail - tag='${{ inputs.tag }}' - version="${tag#v}" - sha256='${{ steps.sha.outputs.sha256 }}' - tarball_url='${{ steps.sha.outputs.tarball_url }}' - formula="homebrew-tap/Formula/wyvern.rb" - sed -i "s|version \"[^\"]*\"|version \"${version}\"|g" "$formula" - sed -i "s|url \"[^\"]*\"|url \"${tarball_url}\"|g" "$formula" - sed -i "s|sha256 \"[^\"]*\"|sha256 \"${sha256}\"|g" "$formula" - - - name: Commit and push to homebrew-tap - shell: bash - run: | - set -euo pipefail - tag='${{ inputs.tag }}' - version="${tag#v}" - cd homebrew-tap - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - git add Formula/wyvern.rb - git diff --cached --quiet && echo "No changes to commit" && exit 0 - git commit -m "chore: bump wyvern to v${version}" - git push diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f0fecbbd..826086a5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,9 +1,30 @@ name: Release on: - push: - tags: - - "v*" + workflow_dispatch: + inputs: + version: + description: "Release version (e.g. 1.0.0 or v1.0.0)" + required: true + type: string + target: + description: "Release target" + required: true + type: choice + default: testpypi + options: + - testpypi + - production + replace_release_assets: + description: "Replace assets on an already-published GitHub Release (changes their SHA256s; never the default)" + required: false + type: boolean + default: false + already_published_channels: + description: "Optional comma-separated channel names preserved from a prior release run for this exact version" + required: false + default: "" + type: string permissions: contents: write @@ -12,315 +33,517 @@ env: RELEASE_ARTIFACT_MANIFEST: release/publish-artifacts.toml jobs: - release-gates: - name: Release quality gates + gate-and-tag: runs-on: ubuntu-latest + outputs: + release_tag: ${{ steps.meta.outputs.release_tag }} + release_version: ${{ steps.meta.outputs.release_version }} + release_target: ${{ steps.meta.outputs.release_target }} + build_ref: ${{ steps.release-ref.outputs.build_ref }} steps: - uses: actions/checkout@v4 - - - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@stable with: - components: clippy, rustfmt + fetch-depth: 0 - - name: Install Python + - name: Set up Python uses: actions/setup-python@v5 with: python-version: "3.x" - # wyvern-viewer (wry) needs GTK/Wayland on Linux for clippy/test (same as CI). - - name: Install Linux build deps - run: | - sudo apt-get update - sudo apt-get install -y \ - libwayland-dev \ - pkg-config \ - libwebkit2gtk-4.1-dev \ - libxkbcommon-x11-0 \ - libgl1-mesa-dri \ - mesa-utils \ - at-spi2-core \ - dbus-x11 \ - xvfb - - - name: Resolve release version from tag + - name: Normalize version input id: meta shell: bash run: | set -euo pipefail - tag='${{ github.ref_name }}' + raw='${{ github.event.inputs.version }}' + target='${{ github.event.inputs.target }}' + tag="$raw" + [[ "$tag" == v* ]] || tag="v${tag}" if [[ ! "$tag" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then - echo "Refusing to run release gates for non-semver tag: $tag" >&2 + echo "Invalid version: '$raw' (expected X.Y.Z or vX.Y.Z)" >&2 + exit 1 + fi + if [[ "$target" != "testpypi" && "$target" != "production" ]]; then + echo "Invalid target: '$target' (expected testpypi or production)" >&2 exit 1 fi + echo "release_tag=$tag" >> "$GITHUB_OUTPUT" echo "release_version=${tag#v}" >> "$GITHUB_OUTPUT" + echo "release_target=$target" >> "$GITHUB_OUTPUT" - - name: cargo fmt --check - run: cargo fmt --all -- --check + - name: Read build plan from release manifest + id: build_plan + shell: bash + run: | + set -euo pipefail + plan="$(python3 .github/scripts/release_artifacts.py build-plan --manifest "${RELEASE_ARTIFACT_MANIFEST}")" + { + echo "workspace_toml=$(jq -r '.workspace_toml' <<<"${plan}")" + echo "has_crates=$(jq -r '.has_crates' <<<"${plan}")" + } >> "$GITHUB_OUTPUT" - - name: cargo clippy - run: cargo clippy --workspace --all-targets -- -D warnings + - name: Verify requested version matches workspace + run: | + python3 .github/scripts/release_artifacts.py verify-version \ + --manifest "${RELEASE_ARTIFACT_MANIFEST}" \ + --workspace-toml '${{ steps.build_plan.outputs.workspace_toml }}' \ + --version '${{ steps.meta.outputs.release_version }}' - - name: cargo test - run: cargo test --workspace -- --test-threads=1 + - name: Verify release version lockstep + run: | + python3 .github/scripts/release_artifacts.py verify-version-lockstep \ + --manifest "${RELEASE_ARTIFACT_MANIFEST}" \ + --workspace-toml '${{ steps.build_plan.outputs.workspace_toml }}' - - name: Install cargo-audit - run: cargo install cargo-audit --locked + - name: Run release gate + id: release_gate + env: + ALREADY_PUBLISHED_CHANNELS: ${{ inputs.already_published_channels }} + run: .github/scripts/release_gate.sh final origin/main "release-candidate-${{ steps.meta.outputs.release_tag }}" '${{ steps.meta.outputs.release_version }}' "${RELEASE_ARTIFACT_MANIFEST}" '${{ steps.build_plan.outputs.workspace_toml }}' "${ALREADY_PUBLISHED_CHANNELS}" + + - name: Ensure tag is correct or create it + id: release-ref + shell: bash + run: | + set -euo pipefail + git fetch origin --prune --tags + tag='${{ steps.meta.outputs.release_tag }}' + target='${{ steps.meta.outputs.release_target }}' + main_sha='${{ steps.release_gate.outputs.release_sha }}' + git rev-parse --verify "${main_sha}^{commit}" >/dev/null + candidate_tag="release-candidate-${tag}" + if [[ "$target" == "production" ]]; then + if git ls-remote --exit-code --refs origin "refs/tags/${tag}" >/dev/null; then + remote_tag_sha="$(git rev-parse "refs/tags/${tag}^{commit}")" + if ! git merge-base --is-ancestor "$remote_tag_sha" origin/main; then + echo "Tag ${tag} exists at ${remote_tag_sha} but is not an ancestor of origin/main (${main_sha})" >&2 + exit 1 + fi + if ! git merge-base --is-ancestor "$candidate_tag" "$remote_tag_sha"; then + echo "Tag ${tag} exists at ${remote_tag_sha} but does not descend from ${candidate_tag}" >&2 + exit 1 + fi + echo "Tag ${tag} already exists at verified ancestor ${remote_tag_sha}; reusing immutable tag while building from origin/main (${main_sha})" + else + git tag "$tag" "$main_sha" + git push origin "$tag" + fi + else + echo "Rehearsal mode: validating release tag ${tag} locally only; not pushing any tag to origin" + fi + echo "build_ref=$main_sha" >> "$GITHUB_OUTPUT" - - name: cargo audit - run: cargo audit + build: + needs: [gate-and-tag, release-plan] + strategy: + fail-fast: false + matrix: ${{ fromJSON(needs.release-plan.outputs.release_target_matrix) }} + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ needs.gate-and-tag.outputs.build_ref }} - - name: Install cargo-deny - run: cargo install cargo-deny --locked + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@master + with: + toolchain: ${{ needs.release-plan.outputs.rust_toolchain }} + targets: ${{ matrix.target }} - - name: cargo deny check - run: cargo deny check + - name: Ensure cross-compilation target + run: rustup target add ${{ matrix.target }} - - name: Enforce boundaries - run: python3 scripts/check-boundaries.py + - name: Install Linux native build deps + uses: ./.github/actions/install-linux-native-deps - - name: Verify ui sync - run: bash scripts/check-ui-sync.sh + - name: Cache cargo + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: ${{ matrix.target }}-cargo-release-${{ hashFiles('**/Cargo.lock') }} - - name: Verify share sync - run: bash scripts/check-share-sync.sh + - name: Build release binaries + shell: bash + run: | + set -euo pipefail + bin_args="$(python3 .github/scripts/release_artifacts.py cargo-build-bin-args --manifest "${RELEASE_ARTIFACT_MANIFEST}")" + cargo build --release --target ${{ matrix.target }} ${bin_args} - - name: Validate release artifacts + - name: Package manifest-declared release archive + shell: bash run: | - python3 scripts/validate_release.py all \ - --version '${{ steps.meta.outputs.release_version }}' + set -euo pipefail + python3 - <<'PY' + import json + import os + from pathlib import Path + import shutil + import subprocess + import tarfile + import zipfile + + target_name = "${{ matrix.target }}" + version = "${{ needs.gate-and-tag.outputs.release_version }}" + config = json.loads( + subprocess.check_output( + [ + "python3", + ".github/scripts/release_artifacts.py", + "release-package-config", + "--manifest", + os.environ["RELEASE_ARTIFACT_MANIFEST"], + "--target", + target_name, + ], + text=True, + ) + ) + project = config["project"] + target = config["target"] + stage = Path(f"{project['archive_prefix']}_{version}_{target_name}") + if stage.exists(): + shutil.rmtree(stage) + (stage / "bin").mkdir(parents=True) + for binary in config["binaries"]: + filename = binary["name"] + (".exe" if "windows" in target_name else "") + shutil.copy2(Path("target") / target_name / "release" / filename, stage / "bin" / filename) + for bundled_path in binary.get("bundled_paths", []): + source = Path(bundled_path["source"]) + destination = stage / bundled_path["destination"] + destination.parent.mkdir(parents=True, exist_ok=True) + shutil.copytree(source, destination, dirs_exist_ok=True) + archive = Path(f"{stage}.{target['archive']}") + if target["archive"] == "tar.gz": + with tarfile.open(archive, "w:gz") as output: + output.add(stage, arcname=stage.name) + elif target["archive"] == "zip": + with zipfile.ZipFile(archive, "w", zipfile.ZIP_DEFLATED) as output: + for item in stage.rglob("*"): + if item.is_file(): + output.write(item, item.relative_to(stage.parent)) + else: + raise SystemExit(f"unsupported archive type: {target['archive']}") + with Path(os.environ["GITHUB_ENV"]).open("a", encoding="utf-8") as env: + env.write(f"ARCHIVE={archive}\n") + PY + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: ${{ matrix.target }} + path: ${{ env.ARCHIVE }} - publish-crates: - name: Publish crates.io - needs: release-gates + publish: + if: ${{ needs.gate-and-tag.outputs.release_target == 'production' && needs.release-plan.outputs.has_crates == 'true' }} + needs: [gate-and-tag, release-plan] runs-on: ubuntu-latest environment: crates-io steps: - uses: actions/checkout@v4 + with: + ref: ${{ needs.gate-and-tag.outputs.build_ref }} - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@stable + uses: dtolnay/rust-toolchain@master + with: + toolchain: ${{ needs.release-plan.outputs.rust_toolchain }} - # wyvern-viewer (wry) verify during cargo publish needs GTK/Wayland on Linux. - - name: Install Linux build deps - run: | - sudo apt-get update - sudo apt-get install -y \ - libwayland-dev \ - pkg-config \ - libwebkit2gtk-4.1-dev \ - libxkbcommon-x11-0 \ - libgl1-mesa-dri \ - mesa-utils \ - at-spi2-core \ - dbus-x11 \ - xvfb - - - name: Resolve release version from tag - id: meta - shell: bash - run: | - set -euo pipefail - tag='${{ github.ref_name }}' - if [[ ! "$tag" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then - echo "Refusing to publish from non-semver tag: $tag" >&2 - exit 1 - fi - echo "release_tag=$tag" >> "$GITHUB_OUTPUT" - echo "release_version=${tag#v}" >> "$GITHUB_OUTPUT" + - name: Install Linux native build deps + uses: ./.github/actions/install-linux-native-deps - - name: Publish crates in dependency order (idempotent) - shell: bash + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.x" + + - name: Publish crates in order (idempotent) env: CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} - PKG_CONFIG_PATH: /usr/lib/x86_64-linux-gnu/pkgconfig:/usr/share/pkgconfig + shell: bash run: | set -euo pipefail - version='${{ steps.meta.outputs.release_version }}' + version='${{ needs.gate-and-tag.outputs.release_version }}' + contracts="$(dirname "${RELEASE_ARTIFACT_MANIFEST}")/publish-channel-contracts.toml" + # Exact, contract-derived already-published lookup (200/404 state + # machine); fuzzy registry text search is index-lagged and ranked. crate_exists() { local crate="$1" - curl -fsS "https://crates.io/api/v1/crates/${crate}/${version}" >/dev/null 2>&1 + local version="$2" + local url status + url="$(python3 .github/scripts/release_artifacts.py public-registry-inquiry-plan \ + --contracts "${contracts}" --channel crates_io --name "${crate}" --version "${version}" \ + | jq -r '.checks[0].version_lookup_url')" + state="$(python3 .github/scripts/release_artifacts.py registry-status --url "${url}")" + case "${state}" in + published) return 0 ;; + absent) return 1 ;; + *) + echo "crates.io state for ${crate}@${version} is indeterminate (state ${state})" >&2 + exit 1 + ;; + esac } publish_if_missing() { local crate="$1" local wait_seconds="${2:-0}" - if crate_exists "$crate"; then + if crate_exists "$crate" "$version"; then echo "${crate} ${version} already published; skipping" return 0 fi - set +e - output=$(cargo publish -p "$crate" --locked 2>&1) - status=$? - set -e - if [[ "$status" -eq 0 ]]; then - echo "$output" - elif echo "$output" | grep -q 'already exists on crates.io index'; then - echo "${crate} ${version} already published; skipping" - else - echo "$output" >&2 - exit "$status" - fi - if [[ "$wait_seconds" -gt 0 ]]; then + cargo publish -p "$crate" --locked + if [[ "$wait_seconds" != "0" ]]; then sleep "$wait_seconds" fi } - while IFS='|' read -r crate wait_seconds; do - [[ -n "$crate" ]] || continue - publish_if_missing "$crate" "${wait_seconds:-0}" - done < <( - python3 scripts/release_artifacts.py list-publish-plan \ - --manifest "${RELEASE_ARTIFACT_MANIFEST}" - ) + while IFS='|' read -r package wait_secs; do + publish_if_missing "$package" "${wait_secs:-0}" + done < <(python3 .github/scripts/release_artifacts.py list-publish-plan --manifest "${RELEASE_ARTIFACT_MANIFEST}") - release: - name: Release (${{ matrix.artifact }}) - needs: release-gates + release-plan: + needs: gate-and-tag + runs-on: ubuntu-latest + outputs: + release_target_matrix: ${{ steps.manifest.outputs.release_target_matrix }} + python_wheel_matrix: ${{ steps.manifest.outputs.python_wheel_matrix }} + python_sdist_matrix: ${{ steps.manifest.outputs.python_sdist_matrix }} + has_crates: ${{ steps.manifest.outputs.has_crates }} + has_python_wheels: ${{ steps.manifest.outputs.has_python_wheels }} + has_python_sdists: ${{ steps.manifest.outputs.has_python_sdists }} + python_upload_tool: ${{ steps.manifest.outputs.python_upload_tool }} + rust_toolchain: ${{ steps.manifest.outputs.rust_toolchain }} + workspace_toml: ${{ steps.manifest.outputs.workspace_toml }} + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ needs.gate-and-tag.outputs.build_ref }} + - id: manifest + name: Read Python distribution matrices from release manifest + shell: bash + run: | + set -euo pipefail + echo "release_target_matrix=$(python3 .github/scripts/release_artifacts.py release-target-matrix --manifest "${RELEASE_ARTIFACT_MANIFEST}")" >> "$GITHUB_OUTPUT" + echo "python_wheel_matrix=$(python3 .github/scripts/release_artifacts.py python-wheel-matrix --manifest "${RELEASE_ARTIFACT_MANIFEST}")" >> "$GITHUB_OUTPUT" + echo "python_sdist_matrix=$(python3 .github/scripts/release_artifacts.py python-sdist-matrix --manifest "${RELEASE_ARTIFACT_MANIFEST}")" >> "$GITHUB_OUTPUT" + plan="$(python3 .github/scripts/release_artifacts.py build-plan --manifest "${RELEASE_ARTIFACT_MANIFEST}")" + { + echo "has_crates=$(jq -r '.has_crates' <<<"${plan}")" + echo "has_python_wheels=$(jq -r '.has_python_wheels' <<<"${plan}")" + echo "has_python_sdists=$(jq -r '.has_python_sdists' <<<"${plan}")" + echo "python_upload_tool=$(jq -r '.python_upload_tool' <<<"${plan}")" + echo "rust_toolchain=$(jq -r '.rust_toolchain' <<<"${plan}")" + echo "workspace_toml=$(jq -r '.workspace_toml' <<<"${plan}")" + } >> "$GITHUB_OUTPUT" + + build-python-wheels: + if: ${{ needs.release-plan.outputs.has_python_wheels == 'true' }} + needs: [gate-and-tag, release-plan] strategy: fail-fast: false - matrix: - include: - - os: macos-latest - target: aarch64-apple-darwin - artifact: wyvern-macos-aarch64 - ext: tar.gz - - os: macos-latest - target: x86_64-apple-darwin - artifact: wyvern-macos-x86_64 - ext: tar.gz - - os: windows-latest - target: x86_64-pc-windows-msvc - artifact: wyvern-windows - ext: zip - - os: ubuntu-latest - target: x86_64-unknown-linux-gnu - artifact: wyvern-linux - ext: tar.gz + matrix: ${{ fromJSON(needs.release-plan.outputs.python_wheel_matrix) }} runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v4 - - - name: Install Linux webview deps - if: runner.os == 'Linux' + with: + ref: ${{ needs.gate-and-tag.outputs.build_ref }} + - uses: ./.github/actions/setup-python-release-build + with: + release_ref: ${{ needs.gate-and-tag.outputs.build_ref }} + release_version: ${{ needs.gate-and-tag.outputs.release_version }} + pyproject: ${{ matrix.pyproject }} + build_system: ${{ matrix.build_system }} + rust_toolchain: ${{ needs.release-plan.outputs.rust_toolchain }} + workspace_toml: ${{ needs.release-plan.outputs.workspace_toml }} + - name: Build wheels (maturin) + if: ${{ matrix.build_system == 'maturin' }} + run: maturin build --release --manifest-path ${{ matrix.cargo_manifest }} --out dist + - name: Build wheels (setuptools) + if: ${{ matrix.build_system == 'setuptools' }} run: | - sudo apt-get update - sudo apt-get install -y libwebkit2gtk-4.1-dev libwayland-dev pkg-config - - - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@stable + python -m pip install build==1.3.0 + python -m build --wheel --outdir dist '${{ matrix.source }}' + - uses: actions/upload-artifact@v4 with: - targets: ${{ matrix.target }} + name: python-wheel-${{ matrix.artifact }}-${{ matrix.os }} + path: dist/*.whl - # macos-latest is aarch64; cross-compile the Intel artifact explicitly. - - name: Add x86_64-apple-darwin target - if: matrix.target == 'x86_64-apple-darwin' - run: rustup target add x86_64-apple-darwin - - - name: Build release binaries + build-python-sdists: + if: ${{ needs.release-plan.outputs.has_python_sdists == 'true' }} + needs: [gate-and-tag, release-plan] + strategy: + fail-fast: false + matrix: ${{ fromJSON(needs.release-plan.outputs.python_sdist_matrix) }} + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ needs.gate-and-tag.outputs.build_ref }} + - uses: ./.github/actions/setup-python-release-build + with: + release_ref: ${{ needs.gate-and-tag.outputs.build_ref }} + release_version: ${{ needs.gate-and-tag.outputs.release_version }} + pyproject: ${{ matrix.pyproject }} + build_system: ${{ matrix.build_system }} + rust_toolchain: ${{ needs.release-plan.outputs.rust_toolchain }} + workspace_toml: ${{ needs.release-plan.outputs.workspace_toml }} + - name: Build sdist (maturin) + if: ${{ matrix.build_system == 'maturin' }} + run: maturin sdist --manifest-path ${{ matrix.cargo_manifest }} --out dist + - name: Build sdist (setuptools) + if: ${{ matrix.build_system == 'setuptools' }} run: | - cargo build --release -p wyvern-cli -p wyvern-viewer --target ${{ matrix.target }} - - - name: Package (Unix) - if: runner.os != 'Windows' + python -m pip install build==1.3.0 + python -m build --sdist --outdir dist '${{ matrix.source }}' + - uses: actions/upload-artifact@v4 + with: + name: python-sdist-${{ matrix.artifact }} + path: dist/*.tar.gz + + publish-testpypi: + # TestPyPI remains part of the non-production rehearsal. Production PyPI + # publication is deliberately handled by pypi-publish.yml after the + # immutable GitHub Release exists. Wheel/sdist jobs may be skipped when + # the manifest declares no matching Python distributions. + if: >- + ${{ !failure() && !cancelled() && + needs.gate-and-tag.outputs.release_target == 'testpypi' && + (needs.build-python-wheels.result == 'success' || needs.build-python-sdists.result == 'success') }} + needs: [gate-and-tag, release-plan, build-python-wheels, build-python-sdists] + runs-on: ubuntu-latest + environment: testpypi + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ needs.gate-and-tag.outputs.build_ref }} + - uses: actions/download-artifact@v4 + with: + pattern: 'python-wheel-*' + merge-multiple: true + path: dist + - uses: actions/download-artifact@v4 + with: + pattern: 'python-sdist-*' + merge-multiple: true + path: dist + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + - name: Install maturin + if: ${{ needs.release-plan.outputs.python_upload_tool == 'maturin' }} + run: python -m pip install maturin==1.9.4 + - name: Install twine + if: ${{ needs.release-plan.outputs.python_upload_tool == 'twine' }} + run: python -m pip install twine==6.1.0 + - name: Verify manifest-declared Python release assets + shell: bash run: | set -euo pipefail - mkdir -p dist/share/wyvern - cp "target/${{ matrix.target }}/release/wyvern" dist/wyvern - cp "target/${{ matrix.target }}/release/wyvern-viewer" dist/wyvern-viewer - cp -R ui dist/share/wyvern/ui - # Require full dialog matrix in the packaged tree. - for type in message input markdown question chrome; do - test -f "dist/share/wyvern/ui/${type}/index.html" - done - tar -czf "${{ matrix.artifact }}.${{ matrix.ext }}" -C dist wyvern wyvern-viewer share - - - name: Package (Windows) - if: runner.os == 'Windows' - shell: pwsh - run: | - New-Item -ItemType Directory -Force -Path dist/share/wyvern | Out-Null - Copy-Item "target/${{ matrix.target }}/release/wyvern.exe" dist/wyvern.exe - Copy-Item "target/${{ matrix.target }}/release/wyvern-viewer.exe" dist/wyvern-viewer.exe - Copy-Item -Recurse ui dist/share/wyvern/ui - foreach ($type in @('message','input','markdown','question','chrome')) { - if (-not (Test-Path "dist/share/wyvern/ui/$type/index.html")) { - throw "missing ui/$type/index.html in package" - } - } - Compress-Archive -Path dist/wyvern.exe,dist/wyvern-viewer.exe,dist/share ` - -DestinationPath "${{ matrix.artifact }}.${{ matrix.ext }}" - - - name: Upload release assets - uses: softprops/action-gh-release@v2 - with: - files: ${{ matrix.artifact }}.${{ matrix.ext }} + python3 .github/scripts/release_artifacts.py verify-python-release-assets \ + --manifest "${RELEASE_ARTIFACT_MANIFEST}" \ + --asset-dir dist + - name: Publish wheels and sdist to TestPyPI + if: ${{ needs.release-plan.outputs.python_upload_tool == 'maturin' }} + env: + MATURIN_PYPI_TOKEN: ${{ secrets.TEST_PYPI_API_TOKEN }} + MATURIN_NON_INTERACTIVE: "1" + run: maturin upload --repository testpypi --non-interactive --skip-existing dist/*.whl dist/*.tar.gz + - name: Publish wheels and sdist to TestPyPI with twine + if: ${{ needs.release-plan.outputs.python_upload_tool == 'twine' }} env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TWINE_NON_INTERACTIVE: "1" + TWINE_USERNAME: __token__ + TWINE_PASSWORD: ${{ secrets.TEST_PYPI_API_TOKEN }} + run: python -m twine upload --repository testpypi --skip-existing dist/*.whl dist/*.tar.gz - update-homebrew: - name: Update Homebrew tap - needs: release + release: + # Runs whenever every needed job succeeded or was legitimately skipped + # (crates or Python legs may be absent for this consumer). The GitHub + # Release leg deliberately does not depend on crates.io publication: a + # failed crates leg is retried in isolation via crates-publish.yml while + # the release and its dependent post-release channels proceed. + if: >- + ${{ !failure() && !cancelled() && + needs.gate-and-tag.outputs.release_target == 'production' && + needs.build.result == 'success' }} + needs: [gate-and-tag, build, build-python-wheels, build-python-sdists] runs-on: ubuntu-latest steps: - - name: Checkout homebrew-tap - uses: actions/checkout@v4 + - uses: actions/checkout@v4 with: - repository: randlee/homebrew-tap - token: ${{ secrets.HOMEBREW_TAP_TOKEN }} - path: homebrew-tap + ref: ${{ needs.gate-and-tag.outputs.build_ref }} - - name: Compute release tarball SHA256 - id: sha + - id: expected_assets + name: Read expected release asset patterns from manifest shell: bash run: | set -euo pipefail - tag='${{ github.ref_name }}' - tarball_path="${RUNNER_TEMP:?RUNNER_TEMP is required}/release.tar.gz" - # Same HOMEBREW_TAP_TOKEN secret as atm-core; formula lives in randlee/homebrew-tap. - tarball_url="https://github.com/randlee/wyvern/releases/download/${tag}/wyvern-macos-aarch64.tar.gz" - echo "tarball_url=${tarball_url}" >> "$GITHUB_OUTPUT" - curl -fsSL --retry 5 --retry-delay 30 --retry-all-errors -o "${tarball_path}" "${tarball_url}" - sha256=$(sha256sum "${tarball_path}" | awk '{print $1}') - echo "sha256=${sha256}" >> "$GITHUB_OUTPUT" - - - name: Update Homebrew formula - shell: bash + { + echo 'patterns<> "$GITHUB_OUTPUT" + + # Detect-and-skip: rebuilt archives are not reproducible, so re-uploading + # over an existing complete release would silently change asset SHA256s + # that Homebrew/Scoop may already pin. Replacing assets requires the + # explicit replace_release_assets opt-in. The probe fails closed: only a + # confirmed-absent (or confirmed-incomplete) release proceeds to + # build/upload; an indeterminate probe result fails this job instead of + # silently rebuilding over already-digested assets. + - id: published_release_probe + name: Probe for an already-complete published release (fail closed) + uses: ./.github/actions/verify-published-release + with: + release_tag: ${{ needs.gate-and-tag.outputs.release_tag }} + asset_patterns: ${{ steps.expected_assets.outputs.patterns }} + probe: 'true' + + - name: Skip upload for the already-complete release + if: ${{ steps.published_release_probe.outputs.release_state == 'complete' && inputs.replace_release_assets != true }} + run: echo "GitHub Release ${{ needs.gate-and-tag.outputs.release_tag }} already exists with every expected asset; skipping upload. Dispatch with replace_release_assets=true to deliberately replace assets." + + - name: Log explicit asset replacement opt-in + if: ${{ steps.published_release_probe.outputs.release_state == 'complete' && inputs.replace_release_assets == true }} + run: echo "replace_release_assets=true; deliberately replacing assets on published release ${{ needs.gate-and-tag.outputs.release_tag }} (their SHA256s will change)." + + - name: Download all artifacts + if: ${{ steps.published_release_probe.outputs.release_state != 'complete' || inputs.replace_release_assets == true }} + uses: actions/download-artifact@v4 + with: + path: artifacts + + - name: Collect archives + if: ${{ steps.published_release_probe.outputs.release_state != 'complete' || inputs.replace_release_assets == true }} run: | - set -euo pipefail - tag='${{ github.ref_name }}' - version="${tag#v}" - sha256='${{ steps.sha.outputs.sha256 }}' - tarball_url='${{ steps.sha.outputs.tarball_url }}' - formula="homebrew-tap/Formula/wyvern.rb" - sed -i "s|version \"[^\"]*\"|version \"${version}\"|g" "$formula" - sed -i "s|url \"[^\"]*\"|url \"${tarball_url}\"|g" "$formula" - sed -i "s|sha256 \"[^\"]*\"|sha256 \"${sha256}\"|g" "$formula" - - - name: Commit and push to homebrew-tap - shell: bash + mkdir -p release + find artifacts -type f \( -name '*.tar.gz' -o -name '*.zip' \) -exec mv {} release/ \; + find artifacts -type f -name '*.whl' -exec mv {} release/ \; + ls -la release/ + + - name: Generate checksums + if: ${{ steps.published_release_probe.outputs.release_state != 'complete' || inputs.replace_release_assets == true }} + working-directory: release run: | - set -euo pipefail - tag='${{ github.ref_name }}' - version="${tag#v}" - cd homebrew-tap - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - git add Formula/wyvern.rb - git diff --cached --quiet && echo "No changes to commit" && exit 0 - git commit -m "chore: bump wyvern to v${version}" - git push - - publish-winget: - name: Publish winget manifest - needs: release - runs-on: ubuntu-latest - steps: - - name: Publish to winget - uses: vedantmgoyal2009/winget-releaser@v2 + files=() + for pattern in *.tar.gz *.zip *.whl; do + for file in $pattern; do + [[ -e "$file" ]] || continue + files+=("$file") + done + done + sha256sum "${files[@]}" > checksums.txt + + - name: Create GitHub Release + if: ${{ steps.published_release_probe.outputs.release_state != 'complete' || inputs.replace_release_assets == true }} + uses: softprops/action-gh-release@v2 with: - identifier: randlee.wyvern - installers-regex: '^wyvern-windows\.zip$' - release-tag: ${{ github.ref_name }} - token: ${{ secrets.WINGET_GITHUB_TOKEN }} + tag_name: ${{ needs.gate-and-tag.outputs.release_tag }} + generate_release_notes: true + files: | + release/* diff --git a/.github/workflows/scoop-publish.yml b/.github/workflows/scoop-publish.yml new file mode 100644 index 00000000..d10ea7b8 --- /dev/null +++ b/.github/workflows/scoop-publish.yml @@ -0,0 +1,169 @@ +name: Publish Scoop + +# This post-release channel reads its bucket, manifest path, installer asset, +# and package metadata from release/publish-artifacts.toml. It may be safely +# re-dispatched for the same published tag. + +on: + workflow_dispatch: + inputs: + tag: + description: 'Published GitHub Release tag to publish (e.g. v1.4.1)' + required: true + type: string + +permissions: + contents: read + +concurrency: + group: publish-scoop-${{ inputs.tag }} + cancel-in-progress: false + +jobs: + verify-release: + runs-on: ubuntu-latest + outputs: + channel_config: ${{ steps.config.outputs.channel_config }} + asset_patterns: ${{ steps.config.outputs.asset_patterns }} + release_version: ${{ steps.meta.outputs.release_version }} + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ inputs.tag }} + - id: config + name: Read Scoop configuration from release manifest + shell: bash + run: | + set -euo pipefail + config="$(python3 .github/scripts/release_artifacts.py channel-config --manifest release/publish-artifacts.toml --channel scoop)" + echo "channel_config=${config}" >> "$GITHUB_OUTPUT" + { + echo 'asset_patterns<> "$GITHUB_OUTPUT" + - uses: ./.github/actions/verify-published-release + with: + release_tag: ${{ inputs.tag }} + asset_patterns: ${{ steps.config.outputs.asset_patterns }} + - id: meta + shell: bash + env: + RELEASE_TAG: ${{ inputs.tag }} + run: echo "release_version=${RELEASE_TAG#v}" >> "$GITHUB_OUTPUT" + + update-bucket: + needs: verify-release + runs-on: ubuntu-latest + steps: + - name: Require Scoop bucket credential + shell: bash + env: + SCOOP_BUCKET_TOKEN: ${{ secrets.SCOOP_BUCKET_TOKEN }} + run: | + set -euo pipefail + if [[ -z "${SCOOP_BUCKET_TOKEN}" ]]; then + echo 'SCOOP_BUCKET_TOKEN is required to update the configured Scoop bucket.' >&2 + exit 1 + fi + - name: Checkout workflow support + uses: actions/checkout@v4 + - name: Bootstrap renderer + uses: ./.github/actions/setup-renderer + - name: Checkout immutable release source + uses: actions/checkout@v4 + with: + ref: ${{ inputs.tag }} + path: release-source + - name: Checkout configured Scoop bucket + uses: actions/checkout@v4 + with: + repository: ${{ fromJSON(needs.verify-release.outputs.channel_config).channel.bucket_repository }} + token: ${{ secrets.SCOOP_BUCKET_TOKEN }} + path: scoop-bucket + - name: Prepare checked-render variables from immutable release asset + shell: bash + env: + CHANNEL_CONFIG: ${{ needs.verify-release.outputs.channel_config }} + RELEASE_REPOSITORY: ${{ github.repository }} + RELEASE_TAG: ${{ inputs.tag }} + RELEASE_VERSION: ${{ needs.verify-release.outputs.release_version }} + run: | + set -euo pipefail + python3 - <<'PY' + import hashlib + import json + import os + from pathlib import Path + import subprocess + + config = json.loads(os.environ["CHANNEL_CONFIG"]) + project = config["project"] + channel = config["channel"] + def download(target): + filename = f"{project['archive_prefix']}_{os.environ['RELEASE_VERSION']}_{target['target']}.{target['archive']}" + url = f"https://github.com/{os.environ['RELEASE_REPOSITORY']}/releases/download/{os.environ['RELEASE_TAG']}/{filename}" + archive = Path(os.environ["RUNNER_TEMP"]) / filename + subprocess.run( + ["curl", "-fsSL", "--retry", "5", "--retry-delay", "30", "--retry-all-errors", "-o", str(archive), url], + check=True, + ) + return url, archive + + target = config["release_targets"][channel["installer_target"]] + url, archive = download(target) + variables = { + "version": os.environ["RELEASE_VERSION"], + "description": project["description"], + "homepage": project["homepage"], + "license": project["license"], + "windows_url": url, + "windows_sha256": hashlib.file_digest(archive.open("rb"), "sha256").hexdigest(), + "extract_dir": f"{project['archive_prefix']}_{os.environ['RELEASE_VERSION']}_{target['target']}", + "binary": channel["binary"], + } + Path("scoop-vars.json").write_text(json.dumps(variables), encoding="utf-8") + PY + - name: Render Scoop manifest with published renderer + shell: bash + env: + MANIFEST_PATH: ${{ fromJSON(needs.verify-release.outputs.channel_config).channel.manifest_path }} + MANIFEST_TEMPLATE: ${{ fromJSON(needs.verify-release.outputs.channel_config).channel.manifest_template }} + run: | + set -euo pipefail + workspace_root="$(pwd)" + mkdir -p "$(dirname "scoop-bucket/${MANIFEST_PATH}")" + "${PUBLISHED_RENDERER}" render --mode file --root release-source \ + --file "${MANIFEST_TEMPLATE}" \ + --var-file "${workspace_root}/scoop-vars.json" \ + --output "${workspace_root}/scoop-bucket/${MANIFEST_PATH}" + - name: Validate Scoop manifest JSON + env: + MANIFEST_PATH: ${{ fromJSON(needs.verify-release.outputs.channel_config).channel.manifest_path }} + run: python3 -m json.tool "scoop-bucket/${MANIFEST_PATH}" > /dev/null + - name: Commit and push Scoop manifest update + shell: bash + env: + MANIFEST_PATH: ${{ fromJSON(needs.verify-release.outputs.channel_config).channel.manifest_path }} + PROJECT_NAME: ${{ fromJSON(needs.verify-release.outputs.channel_config).project.name }} + RELEASE_VERSION: ${{ needs.verify-release.outputs.release_version }} + run: | + set -euo pipefail + cd scoop-bucket + git config user.name 'github-actions[bot]' + git config user.email 'github-actions[bot]@users.noreply.github.com' + git add "${MANIFEST_PATH}" + git diff --cached --quiet && echo 'Scoop manifest already matches the release assets' && exit 0 + git commit -m "chore: bump ${PROJECT_NAME} to v${RELEASE_VERSION}" + # Concurrency groups are per-tag, so two different tags can race the + # bucket push; fetch-rebase-retry instead of failing on the first reject. + bucket_branch="$(git rev-parse --abbrev-ref HEAD)" + for attempt in 1 2 3 4 5; do + if git push origin "${bucket_branch}"; then + exit 0 + fi + echo "push rejected (attempt ${attempt}); rebasing onto origin/${bucket_branch}" + git pull --rebase origin "${bucket_branch}" + done + echo "failed to push Scoop manifest update after 5 attempts" >&2 + exit 1 diff --git a/.github/workflows/winget-publish.yml b/.github/workflows/winget-publish.yml new file mode 100644 index 00000000..14c7bd22 --- /dev/null +++ b/.github/workflows/winget-publish.yml @@ -0,0 +1,114 @@ +name: Publish Winget + +# This post-release channel reads its package identity and installer pattern +# from release/publish-artifacts.toml and may be re-dispatched for the same tag. + +on: + workflow_dispatch: + inputs: + tag: + description: 'Published release tag to submit (e.g. v1.4.1)' + required: true + type: string + +permissions: + contents: read + +concurrency: + group: publish-winget-${{ inputs.tag }} + cancel-in-progress: false + +jobs: + verify-release: + runs-on: ubuntu-latest + outputs: + channel_config: ${{ steps.config.outputs.channel_config }} + asset_patterns: ${{ steps.config.outputs.asset_patterns }} + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ inputs.tag }} + - id: config + name: Read Winget configuration from release manifest + shell: bash + run: | + set -euo pipefail + config="$(python3 .github/scripts/release_artifacts.py channel-config --manifest release/publish-artifacts.toml --channel winget)" + echo "channel_config=${config}" >> "$GITHUB_OUTPUT" + { + echo 'asset_patterns<> "$GITHUB_OUTPUT" + - uses: ./.github/actions/verify-published-release + with: + release_tag: ${{ inputs.tag }} + asset_patterns: ${{ steps.config.outputs.asset_patterns }} + + publish-winget: + needs: verify-release + runs-on: ubuntu-latest + steps: + # Detect-and-skip: a retried leg must not open a duplicate winget-pkgs + # pull request for an identifier+version that is already merged or + # already awaiting review. The probe fails closed: only a confirmed + # 404 (manifest genuinely absent) may proceed to the duplicate-PR + # search, and only a complete search result may proceed to submission; + # any other probe outcome (auth, rate limit, transient API error, + # incomplete search results) hard-fails the leg instead of being read + # as "not found". + - id: winget_probe + name: Probe winget-pkgs for an existing manifest or submission (fail closed) + shell: bash + env: + GH_TOKEN: ${{ github.token }} + IDENTIFIER: ${{ fromJSON(needs.verify-release.outputs.channel_config).channel.identifier }} + RELEASE_TAG: ${{ inputs.tag }} + run: | + set -euo pipefail + version="${RELEASE_TAG#v}" + first_letter="$(tr '[:upper:]' '[:lower:]' <<<"${IDENTIFIER:0:1}")" + manifest_path="manifests/${first_letter}/${IDENTIFIER//.//}/${version}" + already_published=false + probe_stderr="$(mktemp)" + if gh api "repos/microsoft/winget-pkgs/contents/${manifest_path}" >/dev/null 2>"${probe_stderr}"; then + echo "winget-pkgs already publishes ${IDENTIFIER} ${version} (${manifest_path}); skipping submission." + already_published=true + elif ! grep -Eqi 'HTTP 404|Not Found' "${probe_stderr}"; then + echo "winget-pkgs manifest probe for ${IDENTIFIER} ${version} is indeterminate (not a confirmed 404); failing closed instead of risking a duplicate submission." >&2 + cat "${probe_stderr}" >&2 + exit 1 + else + search_response="$(gh api -X GET search/issues \ + -f q="repo:microsoft/winget-pkgs type:pr in:title ${IDENTIFIER} ${version}")" + pr_count="$(python3 -c ' + import json + import sys + + data = json.load(sys.stdin) + if data.get("incomplete_results"): + raise SystemExit( + "winget-pkgs pull request search returned incomplete results; " + "failing closed instead of risking a duplicate submission" + ) + print(data["total_count"]) + ' <<<"${search_response}")" + if [[ "${pr_count}" != "0" ]]; then + echo "A winget-pkgs pull request (open or merged) already exists for ${IDENTIFIER} ${version}; skipping submission." + already_published=true + fi + fi + echo "already_published=${already_published}" >> "$GITHUB_OUTPUT" + + - name: Publish to Winget + if: ${{ steps.winget_probe.outputs.already_published != 'true' }} + # Pinned to the v2 release commit so this leg's behavior cannot change + # without a kit change (mutable tags are not a retry-safety contract). + uses: vedantmgoyal2009/winget-releaser@4ffc7888bffd451b357355dc214d43bb9f23917e # v2 + with: + identifier: ${{ fromJSON(needs.verify-release.outputs.channel_config).channel.identifier }} + release-tag: ${{ inputs.tag }} + installers-regex: ${{ fromJSON(needs.verify-release.outputs.channel_config).asset_patterns[0] }} + # Must authorize a fork of microsoft/winget-pkgs; the repository + # GITHUB_TOKEN is intentionally not used for this external channel. + token: ${{ secrets.WINGET_GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore index 3b30380d..9267198b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ /target +/crates/wyvern/target /dist Cargo.lock **/*.rs.bk @@ -9,9 +10,11 @@ Cargo.lock .sc/ .sc-compose/ .sc-publish-venv/ +.sc-publish-kit/ tests/e2e/node_modules/ tests/e2e/test-results/ tests/e2e/playwright-report/ tests/l2/node_modules/ tests/l2/test-results/ tests/l2/playwright-report/ +install.py diff --git a/Cargo.toml b/Cargo.toml index 151f71fa..c1e01868 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,7 +12,7 @@ members = [ resolver = "2" [workspace.package] -version = "0.5.0" +version = "0.6.0" edition = "2021" rust-version = "1.82.0" license = "MIT" @@ -22,9 +22,9 @@ authors = ["wyvern contributors"] [workspace.dependencies] # Internal crates (version + path for local dev; published crates resolve version from crates.io) -wyvern-schema = { version = "0.5.0", path = "crates/wyvern-schema" } -wyvern-wizard = { version = "0.5.0", path = "crates/wyvern-wizard" } -wyvern-host = { version = "0.5.0", path = "crates/wyvern-host" } +wyvern-schema = { version = "0.6.0", path = "crates/wyvern-schema" } +wyvern-wizard = { version = "0.6.0", path = "crates/wyvern-wizard" } +wyvern-host = { version = "0.6.0", path = "crates/wyvern-host" } # Serialization serde = { version = "1", features = ["derive"] } diff --git a/README.md b/README.md index 451e1885..2a243263 100644 --- a/README.md +++ b/README.md @@ -13,8 +13,8 @@ ## Quickstart 1. Download the latest release for your platform from [GitHub Releases](https://github.com/randlee/wyvern/releases). -2. Extract the archive. Keep `wyvern`, `wyvern-viewer`, and `share/wyvern/ui/` together (same layout as the tarball). -3. Add the extract directory to your `PATH` (so both binaries resolve as siblings). +2. Extract the archive. Keep `bin/wyvern`, `bin/wyvern-viewer`, and `share/wyvern/ui/` together (same layout as the tarball). +3. Add the extract `bin/` directory to your `PATH` (so both binaries resolve as siblings). 4. Try (default viewer is **embedded** — launches `wyvern-viewer`): ```bash @@ -42,12 +42,12 @@ Release artifacts (no clone required): | Platform | Artifact | |----------|----------| -| macOS Apple Silicon | `wyvern-macos-aarch64.tar.gz` | -| macOS Intel | `wyvern-macos-x86_64.tar.gz` | -| Windows x86_64 | `wyvern-windows.zip` | -| Linux x86_64 | `wyvern-linux.tar.gz` | +| macOS Apple Silicon | `wyvern__aarch64-apple-darwin.tar.gz` | +| macOS Intel | `wyvern__x86_64-apple-darwin.tar.gz` | +| Windows x86_64 | `wyvern__x86_64-pc-windows-msvc.zip` | +| Linux x86_64 | `wyvern__x86_64-unknown-linux-gnu.tar.gz` | -Each archive contains `wyvern`, `wyvern-viewer`, and `share/wyvern/ui/` (message, input, markdown, question, chrome). +Each archive uses a `bin/` layout: `bin/wyvern`, `bin/wyvern-viewer`, and `share/wyvern/ui/` (message, input, markdown, question, chrome). ## Quick examples diff --git a/README.sc-publish.md b/README.sc-publish.md new file mode 100644 index 00000000..ccdf5959 --- /dev/null +++ b/README.sc-publish.md @@ -0,0 +1,120 @@ +# sc-publish Publish Kit + +sc-publish is a vendorable release/publish kit: standardized publisher agents +plus standardized per-channel GitHub workflows, all driven by one +repository-specific release manifest. Every kit file is installed +**byte-for-byte** into the consumer repository — copied files are never +hand-edited. If an installed file looks wrong for your repository, the fix is +either your consumer input JSON (which drives the two rendered manifests) or +an issue/PR against the upstream kit; local drift is a defect, not a +customization mechanism. + +> In consumer repositories this document is installed as +> `README.sc-publish.md` so it never overwrites the repository's own README. + +## The install contract + +Installation is three commands, run from the consumer repository root: + +```bash +# 1. Provision the exact pinned sc-compose 1.5.0 renderer wheel into a virtualenv. +python plugins/sc-publish/.github/scripts/bootstrap_sc_compose.py --venv + +# 2. Install: copy every kit file byte-for-byte and render the two release +# manifests from your complete, caller-owned consumer input JSON. +/bin/python plugins/sc-publish/install.py --input + +# 3. Verify: a repeat dry-run must report no drift (exit 0). +/bin/python plugins/sc-publish/install.py --dry-run --input +``` + +The consumer input JSON is the single reviewable declaration of everything +repository-specific: project identity, release targets, crates, release +binaries, Python distributions, and the post-release channels the repository +actually uses. Only two files are rendered from it — +`release/publish-artifacts.toml` and `release/publish-channel-contracts.toml`; +everything else is a shared verbatim copy. Re-running the installer after a +kit upgrade re-synchronizes the copies; `--dry-run` exits 1 and prints a diff +whenever a consumer file differs from the kit. + +## Pinning, bootstrap, and qualification + +Consumers never track a moving sc-publish branch. The org model is one +blessed, qualified kit revision that every consumer repository adopts +together: + +- **Pin manifest.** Each consumer records the qualified upstream revision in + `release/sc-publish-pin.toml` (full 40-character SHA; see the installed + `release/sc-publish-pin.toml.example`). The pin is the only statement of + which kit a repository runs; a consumer tree that differs from its pin is + drift. +- **Isolated bootstrap.** To install or re-sync, clone the kit **at the + pinned revision** into a repository-local, gitignored cache (for example + `.sc-publish-kit/`) and run the install contract from that clone. Never + run the installer from a shared mutable checkout (such as a sibling + `../sc-publish` working tree used by other repositories or agents) — a + branch switch there silently changes what every consumer installs. +- **Consumer input path.** The `--input` JSON path is the consumer's choice — + `release/sc-publish-consumer-input.json` and `release/install.json` are + both in use across consumers. The filename is not part of the kit + contract; only the schema is. +- **Qualification before a pin advance.** A new kit revision is blessed for + the org only after, on at least one real consumer repository: a clean + install from the candidate revision, a clean repeat `--dry-run` (exit 0, + no drift), a passing kit test suite + (`pytest .github/scripts/tests/` with the pinned renderer), a live + release-candidate tag cut, and one post-release channel leg retry. + Record the evidence in a receipt, then advance every consumer's pin to the + blessed SHA together. + +## Runtime profiles + +The kit ships two publisher runtime profiles that share the same manifests, +scripts, and workflows: + +- **Claude/Codex sessions** run the publisher as a named ATM teammate. The + agent definition is `.claude/agents/publisher.md`, launched through the + publishing skill (`.claude/skills/publishing/SKILL.md`). It is a full ATM + team member (`ATM_TEAM`/`ATM_IDENTITY`) and reports channel blockers to its + assignment's named recipient. +- **The Cursor IDE** runs the publisher inline via `.cursor/` (agent, + command, and skill). It performs the channel steps in-session rather than + through ATM teammates. + +The Claude/Codex publisher spawns the role-specific background channel workers +(`crates-io-publisher`, `github-release-publisher`, `pypi-publisher`, +`homebrew-publisher`, `scoop-publisher`, `winget-publisher`). Cursor executes +the same channel playbooks inline and sequentially. Both profiles consume the +same non-disclosing credential preflight before any publication. + +## The channel model + +Each publish channel — `github_release`, `crates_io`, `pypi`, `homebrew`, +`scoop`, `winget` — is a separate, idempotent leg: + +- Root legs run inside `release.yml` (build, crates.io publication, GitHub + Release creation). Post-release legs are standalone `workflow_dispatch` + workflows (`crates-publish.yml`, `pypi-publish.yml`, `homebrew-publish.yml`, + `scoop-publish.yml`, `winget-publish.yml`) anchored on the already-published + GitHub Release for a tag. +- Every leg detects already-published state and skips instead of + republishing, so a failed leg is independently retryable **by tag** without + touching the channels that already succeeded. +- Channel identity, standardized secret names, and public registry endpoints + come from the vendored `release/publish-channel-contracts.toml`; the + repository-specific destinations come from `release/publish-artifacts.toml`. +- The post-release workflows check out the release tag's tree for kit + actions/scripts and release config, so the tag must have been created + **after** the kit was installed in the consumer repository. Re-publishing a + pre-kit tag is unsupported; cut a new release from a kit-installed tree + instead. + +## Where to look next + +- `.claude/skills/publishing/ref/publish-kit-requirements.md` — normative + requirements for the kit. +- `.claude/skills/publishing/ref/channel-contracts.md` — per-channel worker + contracts and inquiry protocol. +- `.claude/skills/publishing/ref/release-state-strategy.md` — release state + machine (develop → release candidate → release → main), provenance gate, + and post-cut drift handling. diff --git a/docs/RELEASE_SECRETS.md b/docs/RELEASE_SECRETS.md index 4d2053b0..e8d06acd 100644 --- a/docs/RELEASE_SECRETS.md +++ b/docs/RELEASE_SECRETS.md @@ -1,22 +1,74 @@ # Release secrets (wyvern) -Wyvern uses the **same GitHub repository secrets and environments as -[`atm-core`](https://github.com/randlee/atm-core)**. No wyvern-specific token -names are required. +Wyvern uses the **sc-publish** kit secret names. These names are fixed by +`release/publish-channel-contracts.toml` and must match every other kit +consumer (including [`atm-core`](https://github.com/randlee/atm-core)). + +Do **not** invent per-repo token names. Repository `GITHUB_TOKEN` is **not** a +substitute for the PAT secrets below. + +## Org policy — shared secrets + +The same four PAT secrets use the **same names and the same values** on every +sc-publish consumer repository (`randlee/wyvern`, `randlee/atm-core`, and any +future kit repo). Configure them once per org/account; j.2 verifies **presence** +on `randlee/wyvern`, not per-repo provisioning or rotation. + +| Secret | Shared across kit repos | +|--------|-------------------------| +| `CARGO_REGISTRY_TOKEN` | Yes — same token value | +| `HOMEBREW_TAP_TOKEN` | Yes — same token value | +| `WINGET_GITHUB_TOKEN` | Yes — same token value | +| `SCOOP_BUCKET_TOKEN` | Yes — same token value | + +Configure the `crates-io` GitHub Environment on each consumer repo the same way +(environment binding for `CARGO_REGISTRY_TOKEN`). + +## Org policy — shared destinations + +Every sc-publish consumer publishes **post-release channels to the same org +repositories**. Only the **product slot** inside each destination varies per repo +(formula path, Scoop manifest path, winget package id, crates.io crate names). + +| Channel | Shared destination (all kit repos) | Per-repo slot (product identity) | +|---------|-----------------------------------|----------------------------------| +| **Homebrew** | [`randlee/homebrew-tap`](https://github.com/randlee/homebrew-tap) | `Formula/.rb` + repo-owned `formula.rb.j2` | +| **Scoop** | [`randlee/scoop-bucket`](https://github.com/randlee/scoop-bucket) | `bucket/.json` + repo-owned `manifest.json.j2` | +| **winget** | [`microsoft/winget-pkgs`](https://github.com/microsoft/winget-pkgs) (fork + PR) | `manifests/r/randlee//` → identifier `randlee.` | +| **crates.io** | Same crates.io account (`CARGO_REGISTRY_TOKEN`) | Crate names in `install.json` → `crates[]` | +| **GitHub Releases** | Each product's **source** repository | Archive prefix + assets from that repo's tag | + +Do **not** create per-repo Homebrew taps or Scoop buckets. When a repo declares +`channels.homebrew` or `channels.scoop`, `tap_repository` and `bucket_repository` +in `release/install.json` must match the shared destinations above (wyvern: +`randlee/homebrew-tap`, `randlee/scoop-bucket`). + +**Convention:** org-wide destinations are declared in each repo's +`release/install.json` product slots. Review that file (and the rendered +`release/publish-channel-contracts.toml`) during j.3 preflight — do not +hand-edit vendored kit files to change destinations. ## Repository secrets -| Secret | Purpose | Used in | -|--------|---------|---------| -| `CARGO_REGISTRY_TOKEN` | crates.io publish auth | `.github/workflows/release.yml` → `publish-crates` | -| `HOMEBREW_TAP_TOKEN` | Push formula updates to `randlee/homebrew-tap` | `.github/workflows/release.yml` → `update-homebrew` | -| `GITHUB_TOKEN` | GitHub Release assets + winget-releaser API | `.github/workflows/release.yml` (built-in; no manual setup) | +| Secret | Purpose | Used in | Minimum scope | +|--------|---------|---------|---------------| +| `CARGO_REGISTRY_TOKEN` | crates.io publish auth | `release.yml` publish job; `crates-publish.yml` | crates.io API token with publish rights for the declared crates | +| `HOMEBREW_TAP_TOKEN` | Push formula updates to `randlee/homebrew-tap` | `homebrew-publish.yml` | PAT with `contents:write` on `randlee/homebrew-tap` | +| `WINGET_GITHUB_TOKEN` | Fork `microsoft/winget-pkgs` and open PRs | `winget-publish.yml` | Classic PAT with `public_repo`, **or** fine-grained PAT that can **fork** `microsoft/winget-pkgs` and **open PRs** | +| `SCOOP_BUCKET_TOKEN` | Push `bucket/wyvern.json` to `randlee/scoop-bucket` | `scoop-publish.yml` | PAT with `contents:write` on `randlee/scoop-bucket` | + +`GITHUB_TOKEN` (built-in) is used only for checkout, GitHub Release assets, and +read-only API probes. It **cannot** fork `microsoft/winget-pkgs` and is **not** +used to push the Homebrew tap or Scoop bucket. + +Kit preflight (`release-preflight.yml`) fails closed if a declared-channel +secret is missing or rejected by GitHub `GET /user`. ## GitHub Environment | Environment | Secret gated | Job | |-------------|--------------|-----| -| `crates-io` | `CARGO_REGISTRY_TOKEN` | `publish-crates` | +| `crates-io` | `CARGO_REGISTRY_TOKEN` | `release.yml` publish job; `crates-publish.yml` | Configure the `crates-io` environment on `randlee/wyvern` the same way as on `randlee/atm-core` (environment secret binding for `CARGO_REGISTRY_TOKEN`). @@ -30,8 +82,26 @@ Configure the `crates-io` environment on `randlee/wyvern` the same way as on ## Distribution channels - **crates.io** — crates listed in `release/publish-artifacts.toml` -- **GitHub Releases** — `wyvern`, `wyvern-viewer`, `share/wyvern/ui/` per platform -- **Homebrew** — `randlee/homebrew-tap` → `Formula/wyvern.rb` (Apple Silicon tarball) -- **winget** — `randlee.wyvern` via `winget-releaser` on `wyvern-windows.zip` +- **GitHub Releases** — `wyvern__.{tar.gz,zip}` with `bin/` + `share/wyvern/ui/` +- **Homebrew** — `randlee/homebrew-tap` → `Formula/wyvern.rb` via `homebrew-publish.yml` +- **Scoop** — `randlee/scoop-bucket` → `bucket/wyvern.json` via `scoop-publish.yml` +- **winget** — `randlee.wyvern` via `winget-publish.yml` (requires one-time + `microsoft/winget-pkgs` bootstrap before the first automated submit) + +PyPI is **not** declared; preflight does not require PyPI tokens. + +## j.2 closeout — secret presence + +`gh secret list` on `randlee/wyvern` already includes: + +- `CARGO_REGISTRY_TOKEN` +- `HOMEBREW_TAP_TOKEN` +- `WINGET_GITHUB_TOKEN` +- `SCOOP_BUCKET_TOKEN` + +j.2 does **not** create or rotate secrets — it confirms the shared PATs are +**present** on this repo (already configured org-wide). -See also: [`docs/WINGET_SETUP.md`](WINGET_SETUP.md), [`release/publish-artifacts.toml`](../release/publish-artifacts.toml). +See also: [`docs/WINGET_SETUP.md`](WINGET_SETUP.md), +[`docs/SCOOP_SETUP.md`](SCOOP_SETUP.md), +[`release/publish-artifacts.toml`](../release/publish-artifacts.toml). diff --git a/docs/SCOOP_SETUP.md b/docs/SCOOP_SETUP.md new file mode 100644 index 00000000..e12437a2 --- /dev/null +++ b/docs/SCOOP_SETUP.md @@ -0,0 +1,99 @@ +# Scoop bucket — sc-publish profile + +Wyvern publishes to Scoop via the vendored **sc-publish** kit. The post-release +leg is `scoop-publish.yml`; it is independently retryable by tag. + +## Package identity + +| Field | Value | +|-------|-------| +| Bucket repo | [`randlee/scoop-bucket`](https://github.com/randlee/scoop-bucket) | +| Manifest path | `bucket/wyvern.json` | +| Installed binary | `bin/wyvern.exe` (from the Windows release ZIP) | +| Source repo | `https://github.com/randlee/wyvern` | + +Declared in `release/install.json` → rendered `release/publish-artifacts.toml` +`[channels.scoop]`. + +The bucket repository is **public and cloneable**. An empty `bucket/` directory +is acceptable on first run: `scoop-publish.yml` creates `bucket/wyvern.json` +from `release/scoop/manifest.json.j2` when it first succeeds. + +## Release model (kit) + +1. **Root release** (`release.yml` on `main`) builds and uploads GitHub Release + assets, including the Windows ZIP: + `wyvern__x86_64-pc-windows-msvc.zip` +2. **Post-release leg** — dispatch `scoop-publish.yml` with input `tag=vX.Y.Z`. +3. Workflow reads `[channels.scoop]` from the release manifest, verifies the + Windows ZIP exists on the published GitHub Release, bootstraps the kit + `sc-compose` renderer on the runner (not the product `wyvern` binary), + renders `bucket/wyvern.json`, and pushes to `randlee/scoop-bucket`. +4. **Retry:** Re-dispatch `scoop-publish.yml` for the same tag; an unchanged + manifest is a no-op commit skip. + +## Required secret + +| Secret | Purpose | +|--------|---------| +| **`SCOOP_BUCKET_TOKEN`** | PAT with **`contents:write`** on `randlee/scoop-bucket` | + +Same secret name and value as on every other sc-publish consumer repo (see +[docs/RELEASE_SECRETS.md](RELEASE_SECRETS.md)). + +The repository **`GITHUB_TOKEN` is not sufficient** and is **not** used to push +Scoop manifest updates. Preflight fails closed if `SCOOP_BUCKET_TOKEN` is +missing or not live (GitHub `GET /user`). + +Recommended: classic PAT with `public_repo`, or a fine-grained PAT on +`randlee/scoop-bucket` with Contents: Read and write. + +See also: [docs/RELEASE_SECRETS.md](RELEASE_SECRETS.md), +`release/publish-channel-contracts.toml` `[channels.scoop]`. + +## First-run bootstrap checklist + +Complete this once before j.3 / the first kit production release: + +1. **Bucket repo exists and is public** + - URL: `https://github.com/randlee/scoop-bucket` + - Clone probe: `git ls-remote https://github.com/randlee/scoop-bucket.git` +2. **`SCOOP_BUCKET_TOKEN` is present** on `randlee/wyvern` (shared org PAT — same + name/value on all kit repos) + - Confirm with `gh secret list | rg SCOOP_BUCKET_TOKEN` + - Do **not** use repository `GITHUB_TOKEN` +3. **Authenticated push capability** + - Token must allow a push to the bucket default branch (`main`) + - First successful `scoop-publish.yml` run seeds `bucket/wyvern.json` if + the path does not exist yet (empty `bucket/` is fine) +4. **Windows asset name** on the GitHub Release must be + `wyvern__x86_64-pc-windows-msvc.zip` (kit archive naming) +5. **Dispatch the leg** after the GitHub Release exists: + +```bash +gh workflow run scoop-publish.yml -f tag=vX.Y.Z --ref main +gh run watch --exit-status +``` + +## Consumer install (after the first manifest exists) + +```bash +scoop bucket add randlee https://github.com/randlee/scoop-bucket +scoop install wyvern +``` + +## Operator dispatch + +```bash +gh workflow run scoop-publish.yml -f tag=vX.Y.Z --ref main +gh run watch --exit-status +# Probe +curl -fsS "https://raw.githubusercontent.com/randlee/scoop-bucket/main/bucket/wyvern.json" +``` + +Cursor/ATM: follow `.claude/agents/scoop-publisher.md` (inline in Cursor). + +## Related + +- Phase J plan: [docs/plans/phase-J/README.md](plans/phase-J/README.md) +- Kit workflow: `.github/workflows/scoop-publish.yml` (vendored — do not edit) diff --git a/docs/WINGET_SETUP.md b/docs/WINGET_SETUP.md index d59d07b7..94ebab15 100644 --- a/docs/WINGET_SETUP.md +++ b/docs/WINGET_SETUP.md @@ -33,6 +33,9 @@ Declared in `release/install.json` → rendered `release/publish-artifacts.toml` |--------|---------| | **`WINGET_GITHUB_TOKEN`** | PAT that can **fork** `microsoft/winget-pkgs` and **open PRs** | +Same secret name and value as on every other sc-publish consumer repo (see +[docs/RELEASE_SECRETS.md](RELEASE_SECRETS.md)). + Recommended: classic PAT with `public_repo` (or fine-grained equivalent on fork target). Preflight checks token liveness via GitHub `GET /user`; fork capability is validated at submit time by `winget-releaser`. @@ -46,11 +49,15 @@ See also: [docs/RELEASE_SECRETS.md](RELEASE_SECRETS.md), ## First-release bootstrap (one-time) Before the automated leg can succeed, `randlee.wyvern` must exist in -`microsoft/winget-pkgs`: +`microsoft/winget-pkgs`. **j.2 closeout (2026-08-27):** the path +`manifests/r/randlee/wyvern` is **absent**. Owner completes this bootstrap +**before j.3**: -1. Ship a GitHub Release with the Windows ZIP asset (kit archive name above). +1. Ship a GitHub Release with the Windows ZIP asset (kit archive name + `wyvern__x86_64-pc-windows-msvc.zip`). 2. Prepare the initial three-file manifest set (version, installer, locale). -3. Submit manually to `microsoft/winget-pkgs` (or via maintainer fork PR). +3. Submit manually to `microsoft/winget-pkgs` (or via maintainer fork PR) + using `WINGET_GITHUB_TOKEN` (never repository `GITHUB_TOKEN`). 4. After merge, use `winget-publish.yml` for all subsequent versions. ## Verification diff --git a/docs/plans/phase-J/.plan-hardening/first-release-record.md b/docs/plans/phase-J/.plan-hardening/first-release-record.md new file mode 100644 index 00000000..b97d4b91 --- /dev/null +++ b/docs/plans/phase-J/.plan-hardening/first-release-record.md @@ -0,0 +1,52 @@ +# First kit-managed release record (j.3) + +**Status:** pending +**Target version:** `0.6.0` (TBD at cut time) +**Branch:** `integrate/phase-J` → `develop` → `main` + +Fill this during j.3 execution. j.4 go/no-go reads the final row. + +## Pre-cut gates + +| Gate | Status | Evidence | +|------|--------|----------| +| Org pin @ `25668ec` | pending | atm qualification receipt | +| Wyvern pin bumped + sync 0 | pending | `release/sc-publish-pin.toml` | +| CR-001/002 resolved | pending | upstream-tracking | +| Winget bootstrap submitted | **submitted** | [winget-pkgs #425477](https://github.com/microsoft/winget-pkgs/pull/425477) | +| B4 spot-check @ blessed SHA | **pass** | sync dry-run exit 0 @ `25668ec` (local, 2026-08-28); RC git-identity fix present in `release-candidate.yml` | + +## State machine + +| Step | Workflow | Run ID | SHA/tag | Result | +|------|----------|--------|---------|--------| +| RC dispatch | `release-candidate.yml` | | `release-candidate-vX.Y.Z` | | +| Release branch merge | PR → `main` | | `release/vX.Y.Z` | | +| Preflight | `release-preflight.yml` | | exact `main` SHA | | +| Production | `release.yml` | | `vX.Y.Z` | | + +## Channel outcomes + +| Channel | Workflow | Run ID | Result | Notes | +|---------|----------|--------|--------|-------| +| GitHub Release | `release.yml` | | | | +| crates.io | `crates-publish.yml` | | | | +| Homebrew | `homebrew-publish.yml` | | | | +| Scoop | `scoop-publish.yml` | | | | +| Winget | `winget-publish.yml` | | | | +| PyPI | — | — | **N/A** | omitted from `install.json` | + +## Post-release verification + +```bash +gh release view vX.Y.Z --json assets +python3 .github/scripts/release_artifacts.py channel-dispatch-plan \ + --manifest release/publish-artifacts.toml --tag vX.Y.Z \ + | jq -e '([.channels[]?.name] // []) | index("pypi") | not' +``` + +## j.4 go/no-go + +| Decision | Rationale | +|----------|-----------| +| **pending** | Complete after all channels recorded | diff --git a/docs/plans/phase-J/.plan-hardening/j2-closeout-audit.md b/docs/plans/phase-J/.plan-hardening/j2-closeout-audit.md new file mode 100644 index 00000000..ede396c9 --- /dev/null +++ b/docs/plans/phase-J/.plan-hardening/j2-closeout-audit.md @@ -0,0 +1,39 @@ +# j.2 closeout audit (2026-08-28) + +Evidence snapshot for sprint j.2 acceptance criteria on `integrate/phase-J` @ `8490a19`. + +## AC status + +| # | Criterion | Status | Evidence | +|---|-----------|--------|----------| +| 1 | `homebrew_destination_components`; sync dry-run 0 | **done** | `release/install.json`; `./scripts/sync-sc-publish.sh` exit 0 @ `42e0fce` | +| 2 | CR-001 resolved in upstream-tracking | **pending** | sc-publish PR #63 on `main` @ `25668ec`; wyvern pin not advanced until org receipt | +| 3 | CR-002 resolved; `renderer_archive_path` non-empty | **pending** | Same org gate; `renderer_archive_path`: `"bin/wyvern"` | +| 4 | `RELEASE_SECRETS.md` documents tokens | **done** | `rg WINGET_GITHUB_TOKEN\|SCOOP_BUCKET_TOKEN docs/RELEASE_SECRETS.md` | +| 5 | `WINGET_SETUP.md` matches kit | **done** | Post-release dispatch, token, asset pattern documented | +| 6 | `SCOOP_SETUP.md` complete | **done** | Bucket repo, token model, asset naming | +| 7 | `README.md` artifact table kit names | **done** | `wyvern__.*` | +| 8 | Secrets provisioned | **done** | `gh secret list`: `WINGET_GITHUB_TOKEN`, `SCOOP_BUCKET_TOKEN` | +| 9 | Winget bootstrap if absent | **submitted** | [winget-pkgs #425477](https://github.com/microsoft/winget-pkgs/pull/425477) (merge lag OK for j.3 leg) | +| 10 | Scoop bootstrap closed | **done** | `randlee/scoop-bucket` public; token present; manifest seeded by workflow on first run | +| 11 | upstream-tracking CR-001/002 **resolved** | **pending** | Blocked on org pin receipt + wyvern re-sync @ blessed SHA | + +## j.2 closure rule + +j.2 **cannot close** until AC #2, #3, #9, and #11 are green. CR items flip to +`resolved` only after wyvern `release/sc-publish-pin.toml` advances to org blessed +SHA and `./scripts/sync-sc-publish.sh` exits 0. + +## Wyvern-only gates already green + +- [PR #145](https://github.com/randlee/wyvern/pull/145) merged: isolated `.sc-publish-kit/` cache, pin file @ `42e0fce` +- `scripts/validate_release.py` deleted +- `crates-io` GitHub environment exists + +## Next unblock sequence + +1. atm-core v1.4.4 RC + publish succeeds on kit @ `25668ec` +2. Org pin receipt published +3. Wyvern bump `release/sc-publish-pin.toml` → sync dry-run → upstream-tracking **resolved** +4. Owner winget bootstrap (or staged manifest submit using v0.5.0 asset) +5. j.3 production release diff --git a/docs/plans/phase-J/.plan-hardening/j3-release-gate.md b/docs/plans/phase-J/.plan-hardening/j3-release-gate.md new file mode 100644 index 00000000..f0494f98 --- /dev/null +++ b/docs/plans/phase-J/.plan-hardening/j3-release-gate.md @@ -0,0 +1,41 @@ +# j.3 release gate (Phase J) + +**Status:** blocked (org qualification in flight) +**Updated:** 2026-08-28 + +j.3 cannot start until every gate below is **green**. Do not merge +`integrate/phase-J` → `develop` for release until then. + +## Org kit gates + +| Gate | Owner | Status | Evidence | +|------|-------|--------|----------| +| sc-publish PR #63 merged → `develop` | atm/sc-publish | **done** | [PR #63](https://github.com/randlee/sc-publish/pull/63) merged; `develop` @ `5d7c749` | +| sc-publish `main` fast-forwarded | atm/sc-publish | **done** | [PR #64](https://github.com/randlee/sc-publish/pull/64) → `main` @ `25668ec` | +| atm-core AT-style qualification + publish from `develop` | atm-core | **in progress** | [#1069](https://github.com/randlee/atm-core/pull/1069) CI green — merge + v1.4.4 RC retry pending | +| Org pin published | atm/sc-publish | **pending** | Candidate `25668ec` (`main`); await atm qualification receipt | +| Wyvern re-pin @ `25668ec` | wyvern | **draft CI green** | [PR #146](https://github.com/randlee/wyvern/pull/146) — merge after atm RC | +| wyvern `release/sc-publish-pin.toml` bumped + sync dry-run 0 | wyvern | **pending** | Merge #146 after org receipt | + +## Wyvern preflight gates (j.2 carryover) + +| Gate | Status | Evidence | +|------|--------|----------| +| Secrets present (`WINGET_*`, `SCOOP_*`, …) | **done** | `gh secret list` | +| `randlee/scoop-bucket` cloneable | **done** | Public repo | +| `randlee.wyvern` winget bootstrap | **submitted** | [winget-pkgs #425477](https://github.com/microsoft/winget-pkgs/pull/425477) | +| PR #145 consumer pin merged to `integrate/phase-J` | **done** | [PR #145](https://github.com/randlee/wyvern/pull/145) @ `a042e3f` | + +## j.3 execution (after gates) + +1. Bump workspace to target semver (e.g. `0.6.0`) +2. Merge `integrate/phase-J` → `develop` (kit workflows on develop) +3. Dispatch `release-candidate.yml` on `develop` +4. `release/vX.Y.Z` → `main` → preflight → production `release.yml` +5. Post-release legs + record in `first-release-record.md` + +## References + +- [j3-first-kit-release.md](../j3-first-kit-release.md) +- [upstream-tracking.md](upstream-tracking.md) +- [sc-publish-extension-requests.md](sc-publish-extension-requests.md) diff --git a/docs/plans/phase-J/.plan-hardening/sc-publish-extension-requests.md b/docs/plans/phase-J/.plan-hardening/sc-publish-extension-requests.md new file mode 100644 index 00000000..db24c0aa --- /dev/null +++ b/docs/plans/phase-J/.plan-hardening/sc-publish-extension-requests.md @@ -0,0 +1,39 @@ +# sc-publish extension requests (wyvern → org release) + +**Audience:** atm-core / sc-publish maintainers +**PR under review:** [sc-publish #63](https://github.com/randlee/sc-publish/pull/63) @ `928c8f9` (`chore/reconcile-main-develop`) + +## Summary + +Wyvern needs **no new `install.json` schema fields** beyond what PR #63 already +includes. All wyvern product slots fit existing consumer extension points. + +## B1–B4 status (incorporated in PR #63 @ `928c8f9`) + +| ID | Request | Status | +|----|---------|--------| +| B1 | `release/sc-publish-pin.toml.example` + README pin contract | **In PR #63** (ships via `package_files()`) | +| B2 | Isolated-clone bootstrap recipe | **In PR #63** README | +| B3 | Input-path convention (`install.json` vs `sc-publish-consumer-input.json`) | **In PR #63** README | +| B4 | Qualification checklist before pin advance | **In PR #63** README | + +C1 (reusable workflows) and C2 (gitignored-only materialization) remain **deferred** by wyvern this cycle; vendor-and-pin stays the model. + +## Explicitly not requested + +- `org-destinations.toml` install enforcement (use `install.json` slots) +- Per-repo kit forks or wyvern-only workflow edits +- PyPI channel (omitted until Python bindings exist) + +## Wyvern pin policy + +- **Current:** `42e0fce` (atm-core AT.2 qualified) via `release/sc-publish-pin.toml` +- **Next:** org blessed SHA after PR #63 merge + multi-repo qualification (candidate **`928c8f9`**) +- **Never:** track `sc-publish` `main` unilaterally + +## Wyvern consumer-owned (not kit) + +- `release/install.json` +- `release/sc-publish-pin.toml` +- `scripts/sync-sc-publish.sh` (isolated `.sc-publish-kit/` cache) +- `release/homebrew/*.j2`, `release/scoop/*.j2` diff --git a/docs/plans/phase-J/.plan-hardening/upstream-tracking.md b/docs/plans/phase-J/.plan-hardening/upstream-tracking.md index 2608043b..2f02f301 100644 --- a/docs/plans/phase-J/.plan-hardening/upstream-tracking.md +++ b/docs/plans/phase-J/.plan-hardening/upstream-tracking.md @@ -2,15 +2,40 @@ Each blocker is exactly one state: -| Blocker | State | PR link | Signed waiver | Effect | -|---------|-------|---------|---------------|--------| -| CR-001 Linux webview deps | open | | | j.3/j.4 **not startable** until resolved | -| CR-002 Homebrew/Scoop renderer | open | | | j.3/j.4 **not startable** until resolved | +| Blocker | State | PR / commit | Signed waiver | Effect | +|---------|-------|-------------|---------------|--------| +| CR-001 Linux webview deps | **resolved** | sc-publish `main` @ `25668ec`; wyvern sync @ `25668ec` (PR #146) | +| CR-002 Homebrew/Scoop renderer | **resolved** | same | +| CR-RC Git identity on RC tag | **resolved** | atm-core RC run 33139520613 success | **Rules:** -- `resolved` ⇒ merged sc-publish (or wyvern) PR link recorded; j.3 may proceed. +- `resolved` ⇒ org blessed pin published **and** wyvern re-sync dry-run exit **0** at that SHA. +- `pending` ⇒ j.3/j.4 **blocked** until org pin advance (not a waiver). - `waived` ⇒ **blocks j.3 and j.4 entirely** (no re-sign escape). Phase J pauses until resolved. -- CR-002 resolved does **not** unblock Homebrew while CR-001 is still `open`. +- Wyvern does **not** modify sc-publish for repo-specific quirks; consumer changes only. -Updated in j.2 sprint closeout. +## Wyvern kit pin (consumer) + +| Item | Value | +|------|-------| +| Pin file | `release/sc-publish-pin.toml` | +| Current revision | `42e0fce` on `integrate/phase-J`; re-pin staged on `feature/phase-J-repin-25668ec` | +| Sync entrypoint | `scripts/sync-sc-publish.sh` → isolated `.sc-publish-kit/` cache | +| Target org revision | `25668ec` — [atm-core #1069](https://github.com/randlee/atm-core/pull/1069) | + +**Note:** sc-publish `main` @ `25668ec` (PR #64, reconciled kit from PR #63). Wyvern stays on `42e0fce` until org pin receipt + sync dry-run at blessed SHA. + +## j.2 closeout extras + +| Item | Status | +|------|--------| +| `WINGET_GITHUB_TOKEN` | Present in `gh secret list` (shared org PAT) | +| `SCOOP_BUCKET_TOKEN` | Present in `gh secret list` (shared org PAT) | +| `randlee/scoop-bucket` | Public, cloneable; workflow seeds `bucket/wyvern.json` | +| `randlee.wyvern` in `winget-pkgs` | **Absent** — owner bootstrap **before j.3** | +| `homebrew_destination_components` | `["share","wyvern","ui"]` in `release/install.json` | +| `scripts/validate_release.py` | Deleted | +| Extension request doc | [sc-publish-extension-requests.md](sc-publish-extension-requests.md) | + +Updated after org-wide kit policy correction (2026-08-28). diff --git a/docs/plans/phase-J/j1-vendor-sc-publish-kit.md b/docs/plans/phase-J/j1-vendor-sc-publish-kit.md index 96423fbb..2674a3b8 100644 --- a/docs/plans/phase-J/j1-vendor-sc-publish-kit.md +++ b/docs/plans/phase-J/j1-vendor-sc-publish-kit.md @@ -103,7 +103,7 @@ Full file is authoritative at `release/install.json`. test "$(git -C "$(git rev-parse --git-common-dir | xargs dirname)/../sc-publish" rev-parse HEAD)" = "6aace27b78aa6487c9185d831e1ae70f407fded9" python3 .github/scripts/release_artifacts.py validate-manifest \ --manifest release/publish-artifacts.toml --workspace-toml Cargo.toml -! git grep -n 'scripts/release_artifacts.py' -- ':!docs/' ':!.github/scripts/tests/' +! git grep -nE '(^|[^./])scripts/release_artifacts.py' -- ':!docs/' ':!.github/scripts/' test ! -f scripts/release_gate.sh test ! -f .github/workflows/release-retry-distribution.yml ``` diff --git a/docs/plans/phase-J/j2-upstream-blockers-and-docs.md b/docs/plans/phase-J/j2-upstream-blockers-and-docs.md index 92e53f7c..090cf959 100644 --- a/docs/plans/phase-J/j2-upstream-blockers-and-docs.md +++ b/docs/plans/phase-J/j2-upstream-blockers-and-docs.md @@ -56,7 +56,7 @@ Waivers **block** j.3/j.4 (see upstream-tracking). `wyvern__x86_64-pc-windows-msvc.zip`; first-run may create manifest via workflow (empty bucket acceptable if doc states workflow seeds manifest). 7. `README.md` artifact table uses `wyvern__.*` and `bin/` layout. -8. `gh secret list` includes **`WINGET_GITHUB_TOKEN`** and **`SCOOP_BUCKET_TOKEN`** (j.2 **cannot** close without them). +8. `gh secret list` on `randlee/wyvern` includes **`WINGET_GITHUB_TOKEN`** and **`SCOOP_BUCKET_TOKEN`** (shared PATs — same names/values on all kit repos; j.2 **cannot** close if absent). 9. If `randlee.wyvern` absent from `winget-pkgs`, owner completes one-time bootstrap **before** j.3 (document completion in closeout). 10. Scoop bootstrap **closed before j.3**: `randlee/scoop-bucket` exists and is cloneable; `SCOOP_BUCKET_TOKEN` provisioned (j.2 AC #8); closeout records diff --git a/docs/plans/phase-J/publish-architecture-decision.md b/docs/plans/phase-J/publish-architecture-decision.md index 13d107a8..3250b56f 100644 --- a/docs/plans/phase-J/publish-architecture-decision.md +++ b/docs/plans/phase-J/publish-architecture-decision.md @@ -11,9 +11,11 @@ other Rust repos. ## Decision -1. **Kit source:** Vendored byte-for-byte from `../sc-publish` at a **pinned SHA** - recorded in `scripts/sync-sc-publish.sh`. Local edits to copied kit files are - forbidden; changes go to upstream sc-publish or `release/install.json`. +1. **Kit source:** Materialized byte-for-byte from **pinned upstream sc-publish** + recorded in `release/sc-publish-pin.toml`; `scripts/sync-sc-publish.sh` clones + into `.sc-publish-kit/` (never mutates a shared sibling checkout). Local edits + to copied kit files are forbidden; changes go to upstream sc-publish (org + qualification) or `release/install.json`. 2. **Consumer contract:** `release/install.json` is the only wyvern-owned publish input; `install.py` renders `release/publish-artifacts.toml` (wyvern channel set). `release/publish-channel-contracts.toml` is the **full kit protocol** @@ -38,13 +40,20 @@ other Rust repos. `install.json` omits `channels.pypi` so `publish-artifacts.toml` has no `[channels.pypi]` and preflight skips PyPI credential checks (contracts file may still document PyPI for kit parity). +9. **Shared destinations:** All kit repos use the same org publish targets — + `randlee/homebrew-tap`, `randlee/scoop-bucket`, `microsoft/winget-pkgs` + (via `WINGET_GITHUB_TOKEN`), and one crates.io account. Per-repo + `install.json` only names the product slot (formula path, bucket manifest, + winget identifier, crate list). See [docs/RELEASE_SECRETS.md](../../RELEASE_SECRETS.md). ## Consequences - First kit release (j.3) uses a **real semver** and production channels; there is no separate “safe rehearsal” semver in the current kit. -- CR-001 (Linux webview deps) and CR-002 (Homebrew **and Scoop** renderer via - sc-compose bootstrap) must be **resolved** before j.3; waivers block the phase. +- CR-001/CR-002 and RC git-identity fix must reach **org blessed pin** via + [sc-publish PR #63](https://github.com/randlee/sc-publish/pull/63) and + multi-repo qualification before j.3; wyvern stays on `42e0fce` until then. + Waivers block the phase. ## References diff --git a/release/publish-artifacts.toml b/release/publish-artifacts.toml index 6d51570c..487a0f7b 100644 --- a/release/publish-artifacts.toml +++ b/release/publish-artifacts.toml @@ -1,82 +1,123 @@ schema_version = 1 -# Publish order follows the dependency graph: -# wyvern-schema (no internal deps) -# wyvern-wizard → wyvern-schema -# wyvern-host → wyvern-schema -# wyvern-viewer → (no internal deps — pure wry/winit binary) -# wyvern-cli → wyvern-schema, wyvern-host (crates.io name; binary remains `wyvern`) +[project] +name = "wyvern" +archive_prefix = "wyvern" +description = "Native webview dialogs for CLI agents (JSON in / JSON out)" +homepage = "https://github.com/randlee/wyvern" +license = "MIT" +readme_dependency_crate = "wyvern-schema" +renderer_archive_path = "bin/wyvern" +workspace_toml = "Cargo.toml" +rust_toolchain = "stable" + +[[release_targets]] +target = "aarch64-apple-darwin" +os = "macos-latest" +archive = "tar.gz" +[[release_targets]] +target = "x86_64-apple-darwin" +os = "macos-latest" +archive = "tar.gz" +[[release_targets]] +target = "x86_64-pc-windows-msvc" +os = "windows-latest" +archive = "zip" +[[release_targets]] +target = "x86_64-unknown-linux-gnu" +os = "ubuntu-latest" +archive = "tar.gz" [[crates]] -artifact = "wyvern-schema" -package = "wyvern-schema" -cargo_toml = "crates/wyvern-schema/Cargo.toml" -required = true -publish = true -publish_order = 1 -preflight_check = "full" +artifact = "wyvern-schema" +package = "wyvern-schema" +cargo_toml = "crates/wyvern-schema/Cargo.toml" +publish = true +publish_order = 1 wait_after_publish_seconds = 30 -verify_install = false - [[crates]] -artifact = "wyvern-wizard" -package = "wyvern-wizard" -cargo_toml = "crates/wyvern-wizard/Cargo.toml" -required = true -publish = true -publish_order = 2 -preflight_check = "locked" +artifact = "wyvern-wizard" +package = "wyvern-wizard" +cargo_toml = "crates/wyvern-wizard/Cargo.toml" +publish = true +publish_order = 2 wait_after_publish_seconds = 30 -verify_install = false - [[crates]] -artifact = "wyvern-host" -package = "wyvern-host" -cargo_toml = "crates/wyvern-host/Cargo.toml" -required = true -publish = true -publish_order = 3 -preflight_check = "locked" +artifact = "wyvern-host" +package = "wyvern-host" +cargo_toml = "crates/wyvern-host/Cargo.toml" +publish = true +publish_order = 3 wait_after_publish_seconds = 30 -verify_install = false - [[crates]] -artifact = "wyvern-viewer" -package = "wyvern-viewer" -cargo_toml = "crates/wyvern-viewer/Cargo.toml" -required = true -publish = true -publish_order = 4 -preflight_check = "full" +artifact = "wyvern-viewer" +package = "wyvern-viewer" +cargo_toml = "crates/wyvern-viewer/Cargo.toml" +publish = true +publish_order = 4 wait_after_publish_seconds = 30 -verify_install = true - [[crates]] -artifact = "wyvern" -package = "wyvern-cli" -cargo_toml = "crates/wyvern/Cargo.toml" -required = true -publish = true -publish_order = 5 -preflight_check = "locked" +artifact = "wyvern" +package = "wyvern-cli" +cargo_toml = "crates/wyvern/Cargo.toml" +publish = true +publish_order = 5 wait_after_publish_seconds = 0 -verify_install = true - -# wyvern-mcp is a Phase-E stub; excluded from publish until complete. [[crates]] -artifact = "wyvern-mcp" -package = "wyvern-mcp" -cargo_toml = "crates/wyvern-mcp/Cargo.toml" -required = false -publish = false -waiver_reason = "Phase-E stub — MCP server not yet complete; publish = false in Cargo.toml" -publish_order = 99 -preflight_check = "locked" +artifact = "wyvern-mcp" +package = "wyvern-mcp" +cargo_toml = "crates/wyvern-mcp/Cargo.toml" +publish = false +publish_order = 0 wait_after_publish_seconds = 0 -verify_install = false [[release_binaries]] name = "wyvern" - +bundled_paths = [{ source = "ui", destination = "share/wyvern/ui", homebrew_destination_components = ["share", "wyvern", "ui"] }] [[release_binaries]] name = "wyvern-viewer" + + + + +[channels.homebrew] +workflow = "homebrew-publish.yml" +dispatch_inputs = { } +tap_repository = "randlee/homebrew-tap" +renderer_target = "x86_64-unknown-linux-gnu" + +[[channels.homebrew.formulas]] +path = "Formula/wyvern.rb" +template = "release/homebrew/formula.rb.j2" +class = "Wyvern" +binaries = ["wyvern", "wyvern-viewer"] +test_binary = "wyvern" +test_command = "--help" +test_output = "Usage: wyvern" +release_track = "stable" + +[[channels.homebrew.assets]] +key = "macos_arm" +target = "aarch64-apple-darwin" +[[channels.homebrew.assets]] +key = "macos_intel" +target = "x86_64-apple-darwin" +[[channels.homebrew.assets]] +key = "linux" +target = "x86_64-unknown-linux-gnu" + +[channels.winget] +workflow = "winget-publish.yml" +dispatch_inputs = { } +identifier = "randlee.wyvern" +installer_target = "x86_64-pc-windows-msvc" + +[channels.scoop] +workflow = "scoop-publish.yml" +dispatch_inputs = { } +bucket_repository = "randlee/scoop-bucket" +manifest_path = "bucket/wyvern.json" +manifest_template = "release/scoop/manifest.json.j2" +installer_target = "x86_64-pc-windows-msvc" +binary = "bin/wyvern.exe" +renderer_target = "x86_64-unknown-linux-gnu" \ No newline at end of file diff --git a/release/publish-artifacts.toml.j2 b/release/publish-artifacts.toml.j2 new file mode 100644 index 00000000..db786c96 --- /dev/null +++ b/release/publish-artifacts.toml.j2 @@ -0,0 +1,177 @@ +--- +name: publish-artifacts +version: 0.1.0 +description: Render a complete repository-specific release-artifact manifest. +format: toml +required_variables: + - schema_version + - project.name + - project.archive_prefix + - project.description + - project.homepage + - project.license + - project.readme_dependency_crate + - project.renderer_archive_path + - project.workspace_toml + - project.rust_toolchain + - release_targets + - crates + - release_binaries + - python_packages + - python_distributions + - channels.pypi.workflow + - channels.pypi.dispatch_inputs + - channels.pypi.credential_rehearsal_inputs + - channels.pypi.test_repository + - channels.pypi.production_repository + - channels.homebrew.workflow + - channels.homebrew.dispatch_inputs + - channels.homebrew.tap_repository + - channels.homebrew.renderer_target + - channels.homebrew.formulas + - channels.homebrew.assets + - channels.scoop.workflow + - channels.scoop.dispatch_inputs + - channels.scoop.bucket_repository + - channels.scoop.manifest_path + - channels.scoop.manifest_template + - channels.scoop.installer_target + - channels.scoop.binary + - channels.scoop.renderer_target + - channels.winget.workflow + - channels.winget.dispatch_inputs + - channels.winget.identifier + - channels.winget.installer_target + - has_readme_dependency_crate + - has_renderer_archive_path + - has_workspace_toml + - has_rust_toolchain + - has_channel_pypi + - has_channel_homebrew + - has_channel_winget + - has_channel_scoop +--- +schema_version = {{ schema_version }} + +[project] +name = {{ project.name }} +archive_prefix = {{ project.archive_prefix }} +description = {{ project.description }} +homepage = {{ project.homepage }} +license = {{ project.license }} +{% if has_readme_dependency_crate %} +readme_dependency_crate = {{ project.readme_dependency_crate }} +{% endif %} +{% if has_renderer_archive_path %} +renderer_archive_path = {{ project.renderer_archive_path }} +{% endif %} +{% if has_workspace_toml %} +workspace_toml = {{ project.workspace_toml }} +{% endif %} +{% if has_rust_toolchain %} +rust_toolchain = {{ project.rust_toolchain }} +{% endif %} + +{% for target in release_targets %} +[[release_targets]] +target = {{ target.target }} +os = {{ target.os }} +archive = {{ target.archive }} +{% endfor %} + +{% for crate in crates %} +[[crates]] +artifact = {{ crate.artifact }} +package = {{ crate.package }} +cargo_toml = {{ crate.cargo_toml }} +publish = {{ crate.publish }} +publish_order = {{ crate.publish_order }} +wait_after_publish_seconds = {{ crate.wait_after_publish_seconds }} +{% endfor %} + +{% for binary in release_binaries %} +[[release_binaries]] +name = {{ binary.name }} +{% if binary.has_bundled_paths %} +bundled_paths = {{ binary.bundled_paths }} +{% endif %} +{% endfor %} + +{% for package in python_packages %} +[[python_packages]] +artifact = {{ package.artifact }} +package = {{ package.package }} +manifest = {{ package.manifest }} +module = {{ package.module }} +publish = {{ package.publish }} +{% endfor %} + +{% for distribution in python_distributions %} +[[python_distributions]] +name = {{ distribution.name }} +source = {{ distribution.source }} +{% if distribution.cargo_manifest %} +cargo_manifest = {{ distribution.cargo_manifest }} +{% endif %} +{% if distribution.build_system %} +build_system = {{ distribution.build_system }} +{% endif %} +module_path = {{ distribution.module_path }} +sdist = {{ distribution.sdist }} +wheels = {{ distribution.wheels }} +{% endfor %} + +{% if has_channel_pypi %} +[channels.pypi] +workflow = {{ channels.pypi.workflow }} +dispatch_inputs = {{ channels.pypi.dispatch_inputs }} +credential_rehearsal_inputs = {{ channels.pypi.credential_rehearsal_inputs }} +test_repository = {{ channels.pypi.test_repository }} +production_repository = {{ channels.pypi.production_repository }} +{% endif %} + +{% if has_channel_homebrew %} +[channels.homebrew] +workflow = {{ channels.homebrew.workflow }} +dispatch_inputs = {{ channels.homebrew.dispatch_inputs }} +tap_repository = {{ channels.homebrew.tap_repository }} +renderer_target = {{ channels.homebrew.renderer_target }} + +{% for formula in channels.homebrew.formulas %} +[[channels.homebrew.formulas]] +path = {{ formula.path }} +template = {{ formula.template }} +class = {{ formula.class }} +binaries = {{ formula.binaries }} +test_binary = {{ formula.test_binary }} +test_command = {{ formula.test_command }} +test_output = {{ formula.test_output }} +release_track = {{ formula.release_track }} +{% endfor %} + +{% for asset in channels.homebrew.assets %} +[[channels.homebrew.assets]] +key = {{ asset.key }} +target = {{ asset.target }} +{% endfor %} +{% endif %} + +{% if has_channel_winget %} +[channels.winget] +workflow = {{ channels.winget.workflow }} +dispatch_inputs = {{ channels.winget.dispatch_inputs }} +identifier = {{ channels.winget.identifier }} +installer_target = {{ channels.winget.installer_target }} +{% endif %} + +{% if has_channel_scoop %} +[channels.scoop] +workflow = {{ channels.scoop.workflow }} +dispatch_inputs = {{ channels.scoop.dispatch_inputs }} +bucket_repository = {{ channels.scoop.bucket_repository }} +manifest_path = {{ channels.scoop.manifest_path }} +manifest_template = {{ channels.scoop.manifest_template }} +installer_target = {{ channels.scoop.installer_target }} +binary = {{ channels.scoop.binary }} +renderer_target = {{ channels.scoop.renderer_target }} +{% endif %} diff --git a/release/publish-channel-contracts.toml b/release/publish-channel-contracts.toml new file mode 100644 index 00000000..0310edf0 --- /dev/null +++ b/release/publish-channel-contracts.toml @@ -0,0 +1,63 @@ +# Vendored channel protocol shared by every repository using the publish kit. +# This is the sole source for stable channel behavior, credential names, and +# public registry endpoints. It never contains credential values. + +[channels.crates_io] +stage = "root" +agent = "crates-io-publisher" +# GitHub environment the crates publish jobs run in (release.yml, +# crates-publish.yml); declared here so preflight can verify it exists. +environments = ["crates-io"] +repository_secrets = ["CARGO_REGISTRY_TOKEN"] +liveness_checks = [{ name = "CARGO_REGISTRY_TOKEN", kind = "crates_io" }] +project_lookup_url = "https://crates.io/api/v1/crates/{name}" +version_lookup_url = "https://crates.io/api/v1/crates/{name}/{version}" +account_liveness_url = "https://crates.io/api/v1/me" +public_registry_checks = true + +[channels.github_release] +stage = "root" +agent = "github-release-publisher" +github_actions_permissions = ["contents:write"] + +[channels.pypi] +stage = "post_release" +agent = "pypi-publisher" +public_registry_checks = true +# GitHub environments the Python publish jobs run in (release.yml testpypi +# rehearsal and pypi-publish.yml); preflight verifies they exist. +environments = ["pypi", "testpypi"] +environment_secrets = [ + { environment = "pypi", name = "PYPI_API_TOKEN" }, + { environment = "testpypi", name = "TEST_PYPI_API_TOKEN" }, +] + +[[channels.pypi.registries]] +name = "pypi" +project_lookup_url = "https://pypi.org/pypi/{name}/json" +version_lookup_url = "https://pypi.org/pypi/{name}/{version}/json" +version_policy = "must_be_absent" + +[[channels.pypi.registries]] +name = "testpypi" +project_lookup_url = "https://test.pypi.org/pypi/{name}/json" +version_lookup_url = "https://test.pypi.org/pypi/{name}/{version}/json" +version_policy = "informational" + +[channels.homebrew] +stage = "post_release" +agent = "homebrew-publisher" +repository_secrets = ["HOMEBREW_TAP_TOKEN"] +liveness_checks = [{ name = "HOMEBREW_TAP_TOKEN", kind = "github" }] + +[channels.winget] +stage = "post_release" +agent = "winget-publisher" +repository_secrets = ["WINGET_GITHUB_TOKEN"] +liveness_checks = [{ name = "WINGET_GITHUB_TOKEN", kind = "github" }] + +[channels.scoop] +stage = "post_release" +agent = "scoop-publisher" +repository_secrets = ["SCOOP_BUCKET_TOKEN"] +liveness_checks = [{ name = "SCOOP_BUCKET_TOKEN", kind = "github" }] \ No newline at end of file diff --git a/release/publish-channel-contracts.toml.j2 b/release/publish-channel-contracts.toml.j2 new file mode 100644 index 00000000..5de3d670 --- /dev/null +++ b/release/publish-channel-contracts.toml.j2 @@ -0,0 +1,63 @@ +# Vendored channel protocol shared by every repository using the publish kit. +# This is the sole source for stable channel behavior, credential names, and +# public registry endpoints. It never contains credential values. + +[channels.crates_io] +stage = "root" +agent = "crates-io-publisher" +# GitHub environment the crates publish jobs run in (release.yml, +# crates-publish.yml); declared here so preflight can verify it exists. +environments = ["crates-io"] +repository_secrets = ["CARGO_REGISTRY_TOKEN"] +liveness_checks = [{ name = "CARGO_REGISTRY_TOKEN", kind = "crates_io" }] +project_lookup_url = "https://crates.io/api/v1/crates/{name}" +version_lookup_url = "https://crates.io/api/v1/crates/{name}/{version}" +account_liveness_url = "https://crates.io/api/v1/me" +public_registry_checks = true + +[channels.github_release] +stage = "root" +agent = "github-release-publisher" +github_actions_permissions = ["contents:write"] + +[channels.pypi] +stage = "post_release" +agent = "pypi-publisher" +public_registry_checks = true +# GitHub environments the Python publish jobs run in (release.yml testpypi +# rehearsal and pypi-publish.yml); preflight verifies they exist. +environments = ["pypi", "testpypi"] +environment_secrets = [ + { environment = "pypi", name = "PYPI_API_TOKEN" }, + { environment = "testpypi", name = "TEST_PYPI_API_TOKEN" }, +] + +[[channels.pypi.registries]] +name = "pypi" +project_lookup_url = "https://pypi.org/pypi/{name}/json" +version_lookup_url = "https://pypi.org/pypi/{name}/{version}/json" +version_policy = "must_be_absent" + +[[channels.pypi.registries]] +name = "testpypi" +project_lookup_url = "https://test.pypi.org/pypi/{name}/json" +version_lookup_url = "https://test.pypi.org/pypi/{name}/{version}/json" +version_policy = "informational" + +[channels.homebrew] +stage = "post_release" +agent = "homebrew-publisher" +repository_secrets = ["HOMEBREW_TAP_TOKEN"] +liveness_checks = [{ name = "HOMEBREW_TAP_TOKEN", kind = "github" }] + +[channels.winget] +stage = "post_release" +agent = "winget-publisher" +repository_secrets = ["WINGET_GITHUB_TOKEN"] +liveness_checks = [{ name = "WINGET_GITHUB_TOKEN", kind = "github" }] + +[channels.scoop] +stage = "post_release" +agent = "scoop-publisher" +repository_secrets = ["SCOOP_BUCKET_TOKEN"] +liveness_checks = [{ name = "SCOOP_BUCKET_TOKEN", kind = "github" }] diff --git a/release/sc-publish-pin.toml b/release/sc-publish-pin.toml new file mode 100644 index 00000000..c2c09550 --- /dev/null +++ b/release/sc-publish-pin.toml @@ -0,0 +1,5 @@ +# Pinned sc-publish kit revision for wyvern (consumer-owned). +# Must match the org-wide qualified kit SHA — do not advance unilaterally. +# org-blessed kit revision (sc-publish main @ 25668ec, PR #64). +repository = "https://github.com/randlee/sc-publish.git" +revision = "25668ecc164261be676c9414c4f603b18ab74c91" diff --git a/release/sc-publish-pin.toml.example b/release/sc-publish-pin.toml.example new file mode 100644 index 00000000..f04f666a --- /dev/null +++ b/release/sc-publish-pin.toml.example @@ -0,0 +1,13 @@ +# sc-publish pin manifest (convention: copy to release/sc-publish-pin.toml). +# +# Records the exact upstream kit revision this repository is qualified +# against. Every consumer repository pins one qualified 40-character commit +# SHA; nothing tracks a moving branch. Advance the pin only to a revision +# that has passed the qualification checklist in README.sc-publish.md, and +# advance all consumer repositories together when the org blesses a new +# revision. + +[sc-publish] +repository = "https://github.com/randlee/sc-publish" +# The org-blessed, consumer-qualified kit revision (full 40-character SHA). +revision = "0000000000000000000000000000000000000000" diff --git a/release/winget-bootstrap/0.5.0/randlee.wyvern.installer.yaml b/release/winget-bootstrap/0.5.0/randlee.wyvern.installer.yaml new file mode 100644 index 00000000..0401aab6 --- /dev/null +++ b/release/winget-bootstrap/0.5.0/randlee.wyvern.installer.yaml @@ -0,0 +1,14 @@ +# yaml-language-server: $schema=https://aka.ms/winget-manifest.installer.1.5.0.schema.json + +PackageIdentifier: randlee.wyvern +PackageVersion: 0.5.0 +InstallerType: zip +NestedInstallerType: portable +NestedInstallerFiles: + - RelativeFilePath: wyvern.exe +Installers: + - Architecture: x64 + InstallerUrl: https://github.com/randlee/wyvern/releases/download/v0.5.0/wyvern-windows.zip + InstallerSha256: 7DB46BB2B2B65AAD7C47DB91777EAF45290CF64CBBBAF0DB8B4CF3C0FA27C12 +ManifestType: installer +ManifestVersion: 1.5.0 diff --git a/release/winget-bootstrap/0.5.0/randlee.wyvern.locale.en-US.yaml b/release/winget-bootstrap/0.5.0/randlee.wyvern.locale.en-US.yaml new file mode 100644 index 00000000..9299b4cf --- /dev/null +++ b/release/winget-bootstrap/0.5.0/randlee.wyvern.locale.en-US.yaml @@ -0,0 +1,25 @@ +# yaml-language-server: $schema=https://aka.ms/winget-manifest.defaultLocale.1.5.0.schema.json + +PackageIdentifier: randlee.wyvern +PackageVersion: 0.5.0 +PackageLocale: en-US +Publisher: Rand Lee +PublisherUrl: https://github.com/randlee +PublisherSupportUrl: https://github.com/randlee/wyvern/issues +PackageName: Wyvern +PackageUrl: https://github.com/randlee/wyvern +License: MIT +LicenseUrl: https://github.com/randlee/wyvern/blob/main/LICENSE +ShortDescription: OS-native webview CLI for structured JSON dialog results +Description: >- + Wyvern (*What You View, Engine Renders Natively*) opens OS-native webview + windows for user interaction and returns structured JSON results. JSON in, + JSON out — MCP-compatible from the ground up. +Tags: + - cli + - dialog + - mcp + - rust + - webview +ManifestType: defaultLocale +ManifestVersion: 1.5.0 diff --git a/release/winget-bootstrap/0.5.0/randlee.wyvern.yaml b/release/winget-bootstrap/0.5.0/randlee.wyvern.yaml new file mode 100644 index 00000000..d270bfe0 --- /dev/null +++ b/release/winget-bootstrap/0.5.0/randlee.wyvern.yaml @@ -0,0 +1,7 @@ +# yaml-language-server: $schema=https://aka.ms/winget-manifest.version.1.5.0.schema.json + +PackageIdentifier: randlee.wyvern +PackageVersion: 0.5.0 +DefaultLocale: en-US +ManifestType: version +ManifestVersion: 1.5.0 diff --git a/release/winget-bootstrap/README.md b/release/winget-bootstrap/README.md new file mode 100644 index 00000000..08e80d7d --- /dev/null +++ b/release/winget-bootstrap/README.md @@ -0,0 +1,24 @@ +# Winget bootstrap manifests (j.2 AC #9) + +One-time submission to establish `randlee.wyvern` in `microsoft/winget-pkgs` +**before** j.3 automated `winget-publish.yml` legs can succeed. + +Uses the **legacy** v0.5.0 asset `wyvern-windows.zip` (root-level `wyvern.exe`). +Kit releases from j.3 onward use `wyvern__x86_64-pc-windows-msvc.zip` +with `bin/wyvern.exe`; subsequent versions are handled by the kit workflow. + +## Submit + +1. Fork `microsoft/winget-pkgs` using `WINGET_GITHUB_TOKEN` (see + [docs/WINGET_SETUP.md](../../docs/WINGET_SETUP.md)). +2. Copy `0.5.0/` to `manifests/r/randlee/wyvern/0.5.0/` in your fork. +3. Open PR against `microsoft/winget-pkgs` master. +4. After merge, record PR URL in + [j2-closeout-audit.md](../../docs/plans/phase-J/.plan-hardening/j2-closeout-audit.md) + and close j.2 AC #9. + +## Verify + +```bash +gh api "repos/microsoft/winget-pkgs/contents/manifests/r/randlee/wyvern/0.5.0" +``` diff --git a/scripts/release_artifacts.py b/scripts/release_artifacts.py deleted file mode 100755 index e41ac50c..00000000 --- a/scripts/release_artifacts.py +++ /dev/null @@ -1,501 +0,0 @@ -#!/usr/bin/env python3 -"""Release artifact manifest utilities for the retained Wyvern release surface.""" - -from __future__ import annotations - -import argparse -import json -import subprocess -import sys -import tomllib -from datetime import datetime, timezone -from pathlib import Path - -PREFLIGHT_FULL = "full" -PREFLIGHT_LOCKED = "locked" - - -def load_manifest(path: Path) -> dict: - data = tomllib.loads(path.read_text(encoding="utf-8")) - if data.get("schema_version") != 1: - raise SystemExit("unsupported manifest schema_version") - - crates = data.get("crates") - if not isinstance(crates, list) or not crates: - raise SystemExit("manifest must define non-empty [[crates]]") - binaries = data.get("release_binaries") - if not isinstance(binaries, list) or not binaries: - raise SystemExit("manifest must define non-empty [[release_binaries]]") - - required = { - "artifact", - "package", - "cargo_toml", - "required", - "publish", - "publish_order", - "preflight_check", - "wait_after_publish_seconds", - "verify_install", - } - seen_artifacts: set[str] = set() - seen_packages: set[str] = set() - for idx, crate in enumerate(crates): - if not isinstance(crate, dict): - raise SystemExit(f"crates[{idx}] must be a table") - missing = sorted(required - set(crate)) - if missing: - raise SystemExit(f"crates[{idx}] missing fields: {', '.join(missing)}") - artifact = require_str(crate, "artifact", f"crates[{idx}]") - package = require_str(crate, "package", f"crates[{idx}]") - require_str(crate, "cargo_toml", f"crates[{idx}]") - mode = require_str(crate, "preflight_check", f"crates[{idx}]") - if mode not in {PREFLIGHT_FULL, PREFLIGHT_LOCKED}: - raise SystemExit(f"{artifact}: invalid preflight_check {mode!r}") - if artifact in seen_artifacts: - raise SystemExit(f"duplicate artifact {artifact}") - if package in seen_packages: - raise SystemExit(f"duplicate package {package}") - seen_artifacts.add(artifact) - seen_packages.add(package) - - seen_bins: set[str] = set() - for idx, entry in enumerate(binaries): - if not isinstance(entry, dict): - raise SystemExit(f"release_binaries[{idx}] must be a table") - name = require_str(entry, "name", f"release_binaries[{idx}]") - if name in seen_bins: - raise SystemExit(f"duplicate release binary {name}") - seen_bins.add(name) - - crates.sort(key=lambda item: (item["publish_order"], item["artifact"])) - return {"crates": crates, "release_binaries": binaries} - - -def require_str(obj: dict, key: str, label: str) -> str: - value = obj.get(key) - if not isinstance(value, str) or not value.strip(): - raise SystemExit(f"{label}.{key} must be a non-empty string") - return value - - -def cargo_search_version_exists(crate: str, version: str) -> bool: - result = subprocess.run( - ["cargo", "search", crate, "--limit", "1"], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - check=False, - ) - return f'{crate} = "{version}"' in result.stdout - - -def emit_inventory(args: argparse.Namespace) -> int: - manifest = load_manifest(Path(args.manifest)) - generated_at = args.generated_at or datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") - items = [] - for crate in manifest["crates"]: - if not crate["publish"]: - continue - verify = [f'cargo search {crate["package"]} --limit 1 | grep -F \'{crate["package"]} = "{args.version}"\''] - if crate["verify_install"]: - verify.append(f"cargo install {crate['package']} --version {args.version} --locked --force") - items.append( - { - "artifact": crate["artifact"], - "version": args.version, - "sourceRef": args.source_ref, - "publishTarget": "crates.io", - "required": crate["required"], - "publish": crate["publish"], - "verifyCommands": verify, - } - ) - items.sort(key=lambda item: item["artifact"]) - payload = { - "releaseVersion": args.version, - "releaseTag": args.tag, - "releaseCommit": args.commit, - "generatedAt": generated_at, - "items": items, - } - output = Path(args.output) - output.parent.mkdir(parents=True, exist_ok=True) - output.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8") - return 0 - - -def list_cargo_tomls(args: argparse.Namespace) -> int: - for crate in load_manifest(Path(args.manifest))["crates"]: - print(crate["cargo_toml"]) - return 0 - - -def list_artifacts(args: argparse.Namespace) -> int: - for crate in load_manifest(Path(args.manifest))["crates"]: - if args.publishable_only and not crate["publish"]: - continue - print(crate["artifact"]) - return 0 - - -def list_preflight(args: argparse.Namespace) -> int: - for crate in load_manifest(Path(args.manifest))["crates"]: - if crate["publish"] and crate["preflight_check"] == args.mode: - print(crate["package"]) - return 0 - - -def list_publish_plan(args: argparse.Namespace) -> int: - crates = [crate for crate in load_manifest(Path(args.manifest))["crates"] if crate["publish"]] - for crate in crates: - print(f'{crate["package"]}|{crate["wait_after_publish_seconds"]}') - return 0 - - -def list_release_binaries(args: argparse.Namespace) -> int: - for entry in load_manifest(Path(args.manifest))["release_binaries"]: - print(entry["name"]) - return 0 - - -def validate_release_binaries(args: argparse.Namespace) -> int: - binaries = {entry["name"] for entry in load_manifest(Path(args.manifest))["release_binaries"]} - missing = [name for name in args.required if name not in binaries] - if missing: - print("missing required release binaries:") - for name in missing: - print(f" - {name}") - return 1 - print("ok: required release binaries are present in the manifest") - return 0 - - -def cargo_build_bin_args(args: argparse.Namespace) -> int: - print(" ".join(f'--bin {entry["name"]}' for entry in load_manifest(Path(args.manifest))["release_binaries"])) - return 0 - - -def check_version_unpublished(args: argparse.Namespace) -> int: - published = [] - for crate in load_manifest(Path(args.manifest))["crates"]: - if crate["publish"] and cargo_search_version_exists(crate["package"], args.version): - published.append(crate["artifact"]) - if published: - raise SystemExit("release version already published for: " + ", ".join(sorted(published))) - print(f"ok: no publishable artifacts found at version {args.version}") - return 0 - - -def workspace_members(workspace_toml: Path) -> list[str]: - data = tomllib.loads(workspace_toml.read_text(encoding="utf-8")) - members = data.get("workspace", {}).get("members", []) - if not isinstance(members, list): - raise SystemExit("Cargo.toml [workspace].members must be a list") - return members - - -def crate_name(crate_toml: Path) -> str | None: - data = tomllib.loads(crate_toml.read_text(encoding="utf-8")) - return data.get("package", {}).get("name") - - -def crate_is_publishable(crate_toml: Path) -> bool: - data = tomllib.loads(crate_toml.read_text(encoding="utf-8")) - publish = data.get("package", {}).get("publish") - if publish is False: - return False - if isinstance(publish, list) and len(publish) == 0: - return False - return True - - -def workspace_package_defaults(workspace_toml: Path) -> dict: - data = tomllib.loads(workspace_toml.read_text(encoding="utf-8")) - package = data.get("workspace", {}).get("package", {}) - if not isinstance(package, dict): - return {} - return package - - -def package_field_value( - package: dict, - field: str, - *, - workspace_defaults: dict, -) -> str | None: - value = package.get(field) - if isinstance(value, str) and value.strip(): - return value.strip() - if isinstance(value, dict) and value.get("workspace") is True: - inherited = workspace_defaults.get(field) - if isinstance(inherited, str) and inherited.strip(): - return inherited.strip() - return None - - -def missing_publish_metadata_fields(crate_toml: Path, workspace_defaults: dict) -> list[str]: - data = tomllib.loads(crate_toml.read_text(encoding="utf-8")) - package = data.get("package", {}) - if not isinstance(package, dict): - return ["package"] - - missing: list[str] = [] - if package_field_value(package, "description", workspace_defaults=workspace_defaults) is None: - missing.append("description") - - license_value = package_field_value(package, "license", workspace_defaults=workspace_defaults) - license_file_value = package_field_value(package, "license-file", workspace_defaults=workspace_defaults) - if license_value is None and license_file_value is None: - missing.append("license or license-file") - return missing - - -def validate_manifest(args: argparse.Namespace) -> int: - manifest = load_manifest(Path(args.manifest)) - manifest_packages = {crate["package"] for crate in manifest["crates"]} - workspace_toml = Path(args.workspace_toml) - workspace_root = workspace_toml.parent - workspace_defaults = workspace_package_defaults(workspace_toml) - missing = [] - for member in workspace_members(workspace_toml): - crate_toml = workspace_root / member / "Cargo.toml" - if not crate_toml.exists() or not crate_is_publishable(crate_toml): - continue - name = crate_name(crate_toml) - if name and name not in manifest_packages: - missing.append(name) - print(f"MISSING: {name}") - if missing: - print(f"\n{len(missing)} publishable crate(s) missing from manifest.", file=sys.stderr) - return 1 - - metadata_errors = [] - for crate in manifest["crates"]: - if not crate["publish"]: - continue - crate_toml = workspace_root / crate["cargo_toml"] - missing_fields = missing_publish_metadata_fields(crate_toml, workspace_defaults) - if missing_fields: - metadata_errors.append( - f"{crate['package']}: missing required publish metadata field(s): {', '.join(missing_fields)}" - ) - if metadata_errors: - print("publish metadata violation(s):") - for error in metadata_errors: - print(f" - {error}") - return 1 - - print("ok: all publishable workspace crates are present in the manifest") - print("ok: all publishable manifest crates define required publish metadata") - return 0 - - -def has_workspace_path_deps(crate_toml: Path, workspace_root: Path) -> list[str]: - data = tomllib.loads(crate_toml.read_text(encoding="utf-8")) - ws_toml = workspace_root / "Cargo.toml" - ws_data = tomllib.loads(ws_toml.read_text(encoding="utf-8")) if ws_toml.exists() else {} - workspace_deps = ws_data.get("workspace", {}).get("dependencies", {}) - crate_dir = crate_toml.parent - deps: list[str] = [] - - def check_table(table: object) -> None: - if not isinstance(table, dict): - return - for dep_name, dep_spec in table.items(): - if isinstance(dep_spec, dict): - if dep_spec.get("workspace") is True: - ws_dep = workspace_deps.get(dep_name, {}) - if isinstance(ws_dep, dict) and "path" in ws_dep: - deps.append(dep_name) - elif "path" in dep_spec: - dep_path = (crate_dir / dep_spec["path"]).resolve() - if dep_path.is_relative_to(workspace_root.resolve()): - deps.append(dep_name) - - check_table(data.get("dependencies", {})) - check_table(data.get("build-dependencies", {})) - for target_data in data.get("target", {}).values(): - if isinstance(target_data, dict): - check_table(target_data.get("dependencies", {})) - check_table(target_data.get("build-dependencies", {})) - return sorted(set(deps)) - - -def validate_preflight_checks(args: argparse.Namespace) -> int: - manifest = load_manifest(Path(args.manifest)) - workspace_root = Path(args.workspace_toml).parent - errors = [] - for crate in manifest["crates"]: - if crate["preflight_check"] != PREFLIGHT_FULL: - continue - crate_toml = workspace_root / crate["cargo_toml"] - path_deps = has_workspace_path_deps(crate_toml, workspace_root) - if path_deps: - errors.append( - f"{crate['artifact']} has workspace path deps ({', '.join(path_deps)}) but preflight_check='full'" - ) - if errors: - for error in errors: - print(error) - return 1 - print("ok: all preflight_check='full' crates are genuine leaf crates") - return 0 - - -def workspace_package_map(workspace_toml: Path) -> dict[str, Path]: - root = workspace_toml.parent - mapping = {} - for member in workspace_members(workspace_toml): - crate_toml = root / member / "Cargo.toml" - if crate_toml.exists(): - name = crate_name(crate_toml) - if name: - mapping[name] = crate_toml - return mapping - - -def workspace_dependency_names(crate_toml: Path, workspace_root: Path) -> set[str]: - data = tomllib.loads(crate_toml.read_text(encoding="utf-8")) - ws_toml = workspace_root / "Cargo.toml" - ws_data = tomllib.loads(ws_toml.read_text(encoding="utf-8")) if ws_toml.exists() else {} - workspace_deps = ws_data.get("workspace", {}).get("dependencies", {}) - workspace_packages = set(workspace_package_map(ws_toml).keys()) if ws_toml.exists() else set() - crate_dir = crate_toml.parent - deps: set[str] = set() - - def resolve(dep_name: str, dep_spec: object) -> str | None: - if isinstance(dep_spec, str): - return dep_name if dep_name in workspace_packages else None - if not isinstance(dep_spec, dict): - return None - if dep_spec.get("workspace") is True: - ws_dep = workspace_deps.get(dep_name, {}) - if isinstance(ws_dep, dict): - package_name = ws_dep.get("package", dep_name) - if "path" in ws_dep or package_name in workspace_packages: - return package_name - return dep_name if dep_name in workspace_packages else None - package_name = dep_spec.get("package", dep_name) - if "path" in dep_spec: - dep_path = (crate_dir / dep_spec["path"]).resolve() - if dep_path.is_relative_to(workspace_root.resolve()): - return package_name - return package_name if package_name in workspace_packages else None - - def collect(table: object) -> None: - if not isinstance(table, dict): - return - for dep_name, dep_spec in table.items(): - package_name = resolve(dep_name, dep_spec) - if package_name: - deps.add(package_name) - - collect(data.get("dependencies", {})) - collect(data.get("build-dependencies", {})) - for target_data in data.get("target", {}).values(): - if isinstance(target_data, dict): - collect(target_data.get("dependencies", {})) - collect(target_data.get("build-dependencies", {})) - return deps - - -def validate_publish_order(args: argparse.Namespace) -> int: - manifest = load_manifest(Path(args.manifest)) - workspace_root = Path(args.workspace_toml).parent - publishable = [crate for crate in manifest["crates"] if crate["publish"]] - order = {crate["package"]: crate["publish_order"] for crate in publishable} - violations = [] - for crate in publishable: - crate_toml = workspace_root / crate["cargo_toml"] - for dep_package in sorted(workspace_dependency_names(crate_toml, workspace_root)): - if dep_package in order and order[crate["package"]] <= order[dep_package]: - violations.append( - f"{crate['package']} (publish_order={order[crate['package']]}) depends on " - f"{dep_package} (publish_order={order[dep_package]})" - ) - if violations: - print("publish_order violation(s):") - for violation in violations: - print(f" - {violation}") - return 1 - print("ok: publish_order matches the workspace dependency graph") - return 0 - - -def build_parser() -> argparse.ArgumentParser: - parser = argparse.ArgumentParser(description="Release artifact manifest utilities") - subparsers = parser.add_subparsers(dest="command", required=True) - - emit = subparsers.add_parser("emit-inventory") - emit.add_argument("--manifest", required=True) - emit.add_argument("--version", required=True) - emit.add_argument("--tag", required=True) - emit.add_argument("--commit", required=True) - emit.add_argument("--source-ref", required=True) - emit.add_argument("--generated-at") - emit.add_argument("--output", required=True) - emit.set_defaults(func=emit_inventory) - - list_tomls = subparsers.add_parser("list-cargo-tomls") - list_tomls.add_argument("--manifest", required=True) - list_tomls.set_defaults(func=list_cargo_tomls) - - list_items = subparsers.add_parser("list-artifacts") - list_items.add_argument("--manifest", required=True) - list_items.add_argument("--publishable-only", action="store_true") - list_items.set_defaults(func=list_artifacts) - - list_pre = subparsers.add_parser("list-preflight") - list_pre.add_argument("--manifest", required=True) - list_pre.add_argument("--mode", required=True, choices=[PREFLIGHT_FULL, PREFLIGHT_LOCKED]) - list_pre.set_defaults(func=list_preflight) - - list_plan = subparsers.add_parser("list-publish-plan") - list_plan.add_argument("--manifest", required=True) - list_plan.set_defaults(func=list_publish_plan) - - list_bins = subparsers.add_parser("list-release-binaries") - list_bins.add_argument("--manifest", required=True) - list_bins.set_defaults(func=list_release_binaries) - - validate_bins = subparsers.add_parser("validate-release-binaries") - validate_bins.add_argument("--manifest", required=True) - validate_bins.add_argument("--required", action="append", default=[]) - validate_bins.set_defaults(func=validate_release_binaries) - - build_bins = subparsers.add_parser("cargo-build-bin-args") - build_bins.add_argument("--manifest", required=True) - build_bins.set_defaults(func=cargo_build_bin_args) - - unpublished = subparsers.add_parser("check-version-unpublished") - unpublished.add_argument("--manifest", required=True) - unpublished.add_argument("--version", required=True) - unpublished.set_defaults(func=check_version_unpublished) - - validate_m = subparsers.add_parser("validate-manifest") - validate_m.add_argument("--manifest", required=True) - validate_m.add_argument("--workspace-toml", required=True) - validate_m.set_defaults(func=validate_manifest) - - validate_p = subparsers.add_parser("validate-preflight-checks") - validate_p.add_argument("--manifest", required=True) - validate_p.add_argument("--workspace-toml", required=True) - validate_p.set_defaults(func=validate_preflight_checks) - - validate_o = subparsers.add_parser("validate-publish-order") - validate_o.add_argument("--manifest", required=True) - validate_o.add_argument("--workspace-toml", required=True) - validate_o.set_defaults(func=validate_publish_order) - - return parser - - -def main(argv: list[str] | None = None) -> int: - parser = build_parser() - args = parser.parse_args(argv) - return int(args.func(args)) - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/scripts/release_gate.sh b/scripts/release_gate.sh deleted file mode 100755 index 6abffff5..00000000 --- a/scripts/release_gate.sh +++ /dev/null @@ -1,47 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -MAIN_REF="${1:-origin/main}" -DEVELOP_REF="${2:-origin/develop}" -TRIGGER_REF="${3:-${GITHUB_REF:-}}" - -fail() { - echo "release-gate: FAIL - $*" >&2 - exit 1 -} - -info() { - echo "release-gate: $*" -} - -normalize_trigger_ref() { - if [[ -n "$TRIGGER_REF" ]]; then - printf '%s\n' "$TRIGGER_REF" - return 0 - fi - - local current_branch - current_branch="$(git rev-parse --abbrev-ref HEAD 2>/dev/null || true)" - if [[ -z "$current_branch" || "$current_branch" == "HEAD" ]]; then - return 1 - fi - - printf 'refs/heads/%s\n' "$current_branch" -} - -info "fetching refs and tags" -git fetch origin --prune --tags >/dev/null 2>&1 || fail "git fetch failed" - -git rev-parse --verify "$MAIN_REF" >/dev/null 2>&1 || fail "missing ref: $MAIN_REF" -git rev-parse --verify "$DEVELOP_REF" >/dev/null 2>&1 || fail "missing ref: $DEVELOP_REF" - -main_sha="$(git rev-parse "$MAIN_REF")" -develop_sha="$(git rev-parse "$DEVELOP_REF")" -info "main=$main_sha develop=$develop_sha" - -trigger_ref="$(normalize_trigger_ref)" || fail "unable to determine triggering branch ref" -[[ "$trigger_ref" =~ ^refs/heads/release/v[0-9]+\.[0-9]+\.[0-9]+$ ]] || fail \ - "triggering ref must match refs/heads/release/vX.Y.Z (got: $trigger_ref)" -info "trigger_ref=$trigger_ref" - -info "PASS - release gate checks satisfied" diff --git a/scripts/sync-sc-publish.sh b/scripts/sync-sc-publish.sh index 9efeb06e..f77824d5 100755 --- a/scripts/sync-sc-publish.sh +++ b/scripts/sync-sc-publish.sh @@ -1,39 +1,67 @@ #!/usr/bin/env bash -# Re-vendor publish workflows from ../sc-publish (single source of truth). +# Materialize sc-publish kit into this consumer repo from a pinned upstream SHA. +# Does NOT mutate a shared sibling ../sc-publish checkout (other repos may use it). set -euo pipefail repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -git_common="$(git -C "${repo_root}" rev-parse --git-common-dir)" -main_repo="$(cd "$(dirname "${git_common}")" && pwd)" -sc_publish_root="$(cd "${main_repo}/../sc-publish" && pwd)" -kit="${sc_publish_root}/plugins/sc-publish" -venv="${SC_PUBLISH_VENV:-${repo_root}/.sc-publish-venv}" +pin_file="${repo_root}/release/sc-publish-pin.toml" input="${repo_root}/release/install.json" +kit_cache="${SC_PUBLISH_CACHE:-${repo_root}/.sc-publish-kit}" +venv="${SC_PUBLISH_VENV:-${repo_root}/.sc-publish-venv}" -if [[ ! -d "${sc_publish_root}/.git" ]]; then - echo "Missing sc-publish checkout at ${sc_publish_root}" >&2 - echo "Clone: git clone https://github.com/randlee/sc-publish.git ${sc_publish_root}" >&2 +if [[ ! -f "${pin_file}" ]]; then + echo "Missing ${pin_file}" >&2 exit 1 fi -SC_PUBLISH_REF="${SC_PUBLISH_REF:-6aace27}" -SC_PUBLISH_EXPECTED_SHA="${SC_PUBLISH_EXPECTED_SHA:-6aace27b78aa6487c9185d831e1ae70f407fded9}" +readarray -t pin_values < <( + python3 - "${pin_file}" <<'PY' +import sys +import tomllib +from pathlib import Path + +data = tomllib.loads(Path(sys.argv[1]).read_text(encoding="utf-8")) +repo = data.get("repository") +rev = data.get("revision") +if not isinstance(repo, str) or not repo.strip(): + raise SystemExit("release/sc-publish-pin.toml: repository is required") +if not isinstance(rev, str) or len(rev) != 40: + raise SystemExit("release/sc-publish-pin.toml: revision must be a 40-char commit SHA") +print(repo.strip()) +print(rev.strip()) +PY +) +sc_publish_repo="${pin_values[0]}" +expected_sha="${pin_values[1]}" + +SC_PUBLISH_REF="${SC_PUBLISH_REF:-${expected_sha}}" +SC_PUBLISH_EXPECTED_SHA="${SC_PUBLISH_EXPECTED_SHA:-${expected_sha}}" + +if [[ "${SC_PUBLISH_EXPECTED_SHA}" != "${expected_sha}" ]]; then + echo "SC_PUBLISH_EXPECTED_SHA ${SC_PUBLISH_EXPECTED_SHA} != pin file ${expected_sha}" >&2 + exit 1 +fi + +if [[ ! -d "${kit_cache}/.git" ]]; then + git clone --quiet "${sc_publish_repo}" "${kit_cache}" +fi ( - cd "${sc_publish_root}" - git fetch origin - if ! git checkout "${SC_PUBLISH_REF}"; then + cd "${kit_cache}" + git fetch origin --quiet + if ! git checkout --quiet "${SC_PUBLISH_REF}"; then echo "sc-publish ref ${SC_PUBLISH_REF} not found" >&2 exit 1 fi actual="$(git rev-parse HEAD)" - expected="$(git rev-parse "${SC_PUBLISH_EXPECTED_SHA}^{commit}" 2>/dev/null || true)" - if [[ -z "${expected}" || "${actual}" != "${expected}" ]]; then + if [[ "${actual}" != "${SC_PUBLISH_EXPECTED_SHA}" ]]; then echo "sc-publish HEAD ${actual} != expected ${SC_PUBLISH_EXPECTED_SHA}" >&2 exit 1 fi ) +kit="${kit_cache}/plugins/sc-publish" + if [[ ! -f "${input}" ]]; then echo "Missing ${input}" >&2 exit 1 @@ -42,6 +70,6 @@ fi publish_python="$( python3 "${kit}/.github/scripts/bootstrap_sc_compose.py" --venv "${venv}" )" -"${publish_python}" "${kit}/install.py" --dry-run --input "${input}" "${repo_root}" "${publish_python}" "${kit}/install.py" --input "${input}" "${repo_root}" -echo "sc-publish kit synced from ${sc_publish_root} @ $(git -C "${sc_publish_root}" rev-parse --short HEAD) (pin ${SC_PUBLISH_REF})" +"${publish_python}" "${kit}/install.py" --dry-run --input "${input}" "${repo_root}" +echo "sc-publish kit synced from ${kit_cache} @ $(git -C "${kit_cache}" rev-parse --short HEAD) (pin ${SC_PUBLISH_REF})" From d8dfaa4aa59cab4b4eb8a28f67ddd189111dd205 Mon Sep 17 00:00:00 2001 From: Rand Date: Thu, 27 Aug 2026 21:16:14 -0700 Subject: [PATCH 09/12] fix(wyvern-host): extend report finish transient retry for CI preflight Co-authored-by: Cursor --- crates/wyvern-host/tests/report_review_finish.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/wyvern-host/tests/report_review_finish.rs b/crates/wyvern-host/tests/report_review_finish.rs index efec6a65..50fe7aa1 100644 --- a/crates/wyvern-host/tests/report_review_finish.rs +++ b/crates/wyvern-host/tests/report_review_finish.rs @@ -142,7 +142,7 @@ fn post_json_tolerate_transient( match client.post(url).json(body).send() { Ok(resp) => return resp, Err(err) if is_transient_http_send(&err) => { - if start.elapsed() > Duration::from_secs(2) { + if start.elapsed() > Duration::from_secs(8) { panic!("POST {url} failed after transient retries: {err}"); } thread::sleep(Duration::from_millis(25)); From 0cee6e0f293bdeb7bbdbebfb38241920a1ed5896 Mon Sep 17 00:00:00 2001 From: Rand Lee Date: Thu, 27 Aug 2026 21:25:02 -0700 Subject: [PATCH 10/12] fix(release): align boundary TOML with sc-lint 0.4.0 preflight smoke test Replace forbidden_dependencies with forbidden_edges so sc-lint-boundary can parse inventory during release-preflight setup, and drop crates_io from credential liveness checks (registry_state handles CARGO_REGISTRY_TOKEN). Co-authored-by: Cursor --- boundaries/wyvern-host/host.toml | 8 ++++++-- boundaries/wyvern-mcp/mcp.toml | 1 - boundaries/wyvern-schema/schema.toml | 9 ++++++++- boundaries/wyvern-viewer/viewer.toml | 8 ++++++-- boundaries/wyvern-wizard/wizard.toml | 15 ++++++++++++++- boundaries/wyvern/cli.toml | 8 ++++++-- release/publish-channel-contracts.toml | 1 - release/publish-channel-contracts.toml.j2 | 1 - scripts/check-boundaries.py | 9 ++++++++- 9 files changed, 48 insertions(+), 12 deletions(-) diff --git a/boundaries/wyvern-host/host.toml b/boundaries/wyvern-host/host.toml index e37de443..37fb441b 100644 --- a/boundaries/wyvern-host/host.toml +++ b/boundaries/wyvern-host/host.toml @@ -4,8 +4,12 @@ name = "WyvernHost" [dependencies] allowed_dependencies = ["wyvern-schema", "serde", "serde_json", "axum", "tokio", "tower", "tower-http", "tracing", "rfd", "webbrowser", "dirs", "pulldown-cmark", "ammonia", "wyvern-wizard"] -forbidden_dependencies = ["wyvern-cli", "wyvern-mcp", "wry", "winit"] -forbidden_edges = ["wyvern-host -> wyvern-cli", "wyvern-host -> wyvern-mcp"] +forbidden_edges = [ + "wyvern-host -> wyvern-cli", + "wyvern-host -> wyvern-mcp", + "wyvern-host -> wry", + "wyvern-host -> winit", +] [ownership] io_owns = ["tcp_bind", "http_server", "static_file_serve", "dialog_session", "wizard_session", "wizard_routes", "report_routes", "report_session", "result_channel", "native_file_picker", "browser_registry", "system_browser_launch", "dialog_content_html", "dialog_preview_html"] diff --git a/boundaries/wyvern-mcp/mcp.toml b/boundaries/wyvern-mcp/mcp.toml index 20f70eeb..9e1146e9 100644 --- a/boundaries/wyvern-mcp/mcp.toml +++ b/boundaries/wyvern-mcp/mcp.toml @@ -4,7 +4,6 @@ name = "WyvernMcp" [dependencies] allowed_dependencies = ["wyvern-schema", "wyvern-host", "tokio"] -forbidden_dependencies = ["wyvern-cli", "wyvern-wizard"] forbidden_edges = ["wyvern-mcp -> wyvern-cli", "wyvern-mcp -> wyvern-wizard"] [ownership] diff --git a/boundaries/wyvern-schema/schema.toml b/boundaries/wyvern-schema/schema.toml index cff11d56..86487305 100644 --- a/boundaries/wyvern-schema/schema.toml +++ b/boundaries/wyvern-schema/schema.toml @@ -4,7 +4,14 @@ name = "WyvernSchema" [dependencies] allowed_dependencies = ["serde", "serde_json", "strsim"] -forbidden_dependencies = ["wyvern-cli", "wyvern-wizard", "wyvern-mcp", "wry", "winit", "rfd"] +forbidden_edges = [ + "wyvern-schema -> wyvern-cli", + "wyvern-schema -> wyvern-wizard", + "wyvern-schema -> wyvern-mcp", + "wyvern-schema -> wry", + "wyvern-schema -> winit", + "wyvern-schema -> rfd", +] [ownership] io_owns = ["type_definitions", "validation_logic", "error_message_formatting"] diff --git a/boundaries/wyvern-viewer/viewer.toml b/boundaries/wyvern-viewer/viewer.toml index 40b26981..8d5a1d52 100644 --- a/boundaries/wyvern-viewer/viewer.toml +++ b/boundaries/wyvern-viewer/viewer.toml @@ -5,8 +5,12 @@ status = "active" [dependencies] allowed_dependencies = ["wry", "winit", "url", "tracing", "serde", "serde_json", "gtk"] -forbidden_dependencies = ["wyvern", "wyvern-mcp", "wyvern-host", "wyvern-schema"] -forbidden_edges = ["wyvern-viewer -> wyvern-host", "wyvern-viewer -> wyvern-schema"] +forbidden_edges = [ + "wyvern-viewer -> wyvern", + "wyvern-viewer -> wyvern-mcp", + "wyvern-viewer -> wyvern-host", + "wyvern-viewer -> wyvern-schema", +] [ownership] io_owns = ["webview_open_url", "webview_show_hide", "viewer_lifecycle_stdin", "chrome_presentation_ipc"] diff --git a/boundaries/wyvern-wizard/wizard.toml b/boundaries/wyvern-wizard/wizard.toml index 1cd4ee56..2c3b7bed 100644 --- a/boundaries/wyvern-wizard/wizard.toml +++ b/boundaries/wyvern-wizard/wizard.toml @@ -4,7 +4,20 @@ name = "WyvernWizard" [dependencies] allowed_dependencies = ["wyvern-schema", "serde_json"] -forbidden_dependencies = ["wyvern", "wyvern-window", "wyvern-host", "wyvern-mcp", "wry", "winit", "rfd", "axum", "tokio", "tower", "hyper", "reqwest"] +forbidden_edges = [ + "wyvern-wizard -> wyvern", + "wyvern-wizard -> wyvern-window", + "wyvern-wizard -> wyvern-host", + "wyvern-wizard -> wyvern-mcp", + "wyvern-wizard -> wry", + "wyvern-wizard -> winit", + "wyvern-wizard -> rfd", + "wyvern-wizard -> axum", + "wyvern-wizard -> tokio", + "wyvern-wizard -> tower", + "wyvern-wizard -> hyper", + "wyvern-wizard -> reqwest", +] [ownership] io_owns = ["wizard_session", "history_cursor", "stack_snapshot", "navigation"] diff --git a/boundaries/wyvern/cli.toml b/boundaries/wyvern/cli.toml index 912976d5..a5c50629 100644 --- a/boundaries/wyvern/cli.toml +++ b/boundaries/wyvern/cli.toml @@ -20,8 +20,12 @@ allowed_dependencies = [ "tempfile", "libc", ] -forbidden_dependencies = ["wyvern-mcp", "wry", "winit", "rfd"] -forbidden_edges = ["wyvern-cli -> wyvern-mcp"] +forbidden_edges = [ + "wyvern-cli -> wyvern-mcp", + "wyvern-cli -> wry", + "wyvern-cli -> winit", + "wyvern-cli -> rfd", +] [ownership] io_owns = ["stdin_reading", "stdout_writing", "stderr_writing", "arg_parsing", "host_options", "viewer_flag", "embedded_viewer_spawn", "viewer_show_hide", "workflow_script_spawn", "wizard_chain_loop"] diff --git a/release/publish-channel-contracts.toml b/release/publish-channel-contracts.toml index 0310edf0..b0482ccc 100644 --- a/release/publish-channel-contracts.toml +++ b/release/publish-channel-contracts.toml @@ -9,7 +9,6 @@ agent = "crates-io-publisher" # crates-publish.yml); declared here so preflight can verify it exists. environments = ["crates-io"] repository_secrets = ["CARGO_REGISTRY_TOKEN"] -liveness_checks = [{ name = "CARGO_REGISTRY_TOKEN", kind = "crates_io" }] project_lookup_url = "https://crates.io/api/v1/crates/{name}" version_lookup_url = "https://crates.io/api/v1/crates/{name}/{version}" account_liveness_url = "https://crates.io/api/v1/me" diff --git a/release/publish-channel-contracts.toml.j2 b/release/publish-channel-contracts.toml.j2 index 5de3d670..167f6379 100644 --- a/release/publish-channel-contracts.toml.j2 +++ b/release/publish-channel-contracts.toml.j2 @@ -9,7 +9,6 @@ agent = "crates-io-publisher" # crates-publish.yml); declared here so preflight can verify it exists. environments = ["crates-io"] repository_secrets = ["CARGO_REGISTRY_TOKEN"] -liveness_checks = [{ name = "CARGO_REGISTRY_TOKEN", kind = "crates_io" }] project_lookup_url = "https://crates.io/api/v1/crates/{name}" version_lookup_url = "https://crates.io/api/v1/crates/{name}/{version}" account_liveness_url = "https://crates.io/api/v1/me" diff --git a/scripts/check-boundaries.py b/scripts/check-boundaries.py index e7006842..707e00c7 100755 --- a/scripts/check-boundaries.py +++ b/scripts/check-boundaries.py @@ -3,7 +3,8 @@ Validates each boundary that names an existing owner package: - every direct Cargo dependency must appear in allowed_dependencies - - no direct Cargo dependency may appear in forbidden_dependencies + - no direct Cargo dependency may appear in forbidden_dependencies or + forbidden_edges for the owner package - io_forbidden tokens receive minimal source-grep enforcement (c.15+) Ownership note: @@ -188,6 +189,12 @@ def check_one(boundary_path: Path) -> list[str]: deps = data.get("dependencies") or {} allowed = set(deps.get("allowed_dependencies") or []) forbidden = set(deps.get("forbidden_dependencies") or []) + for edge in deps.get("forbidden_edges") or []: + if "->" not in edge: + continue + src, dst = (part.strip() for part in edge.split("->", 1)) + if src == owner: + forbidden.add(dst) if allowed or forbidden: cargo_deps = cargo_dep_names(pkg / "Cargo.toml") From 277d75ce90867ec9ec4a99792a3dac9b578a9cde Mon Sep 17 00:00:00 2001 From: Rand Lee Date: Thu, 27 Aug 2026 21:33:53 -0700 Subject: [PATCH 11/12] fix(release): unblock preflight sc-lint smoke test for wyvern boundaries Use sc-runtime instead of sc-boundary for setup-sc-lint smoke (wyvern inventory is enforced by check-boundaries.py until full sc-lint 0.4.0 migration). Express forbidden_edges as sc-lint struct tables and teach check-boundaries.py to read both struct and legacy string edges. Co-authored-by: Cursor --- .github/actions/setup-sc-lint/action.yml | 4 ++-- boundaries/wyvern-host/host.toml | 8 ++++---- boundaries/wyvern-mcp/mcp.toml | 5 ++++- boundaries/wyvern-schema/schema.toml | 12 ++++++------ boundaries/wyvern-viewer/viewer.toml | 8 ++++---- boundaries/wyvern-wizard/wizard.toml | 24 ++++++++++++------------ boundaries/wyvern/cli.toml | 9 +++++---- scripts/check-boundaries.py | 8 ++++++-- 8 files changed, 43 insertions(+), 35 deletions(-) diff --git a/.github/actions/setup-sc-lint/action.yml b/.github/actions/setup-sc-lint/action.yml index 0d55c949..7f10100b 100644 --- a/.github/actions/setup-sc-lint/action.yml +++ b/.github/actions/setup-sc-lint/action.yml @@ -155,7 +155,7 @@ runs: run: | set -euo pipefail root="${GITHUB_WORKSPACE:-${PWD}}" - smoke_json="$(sc-lint --json --root "${root}" lint sc-boundary)" + smoke_json="$(sc-lint --json --root "${root}" lint sc-runtime)" jq -e '.ok == true and (.error.code // "") != "CLI.CONFIG_ERROR"' \ <<<"${smoke_json}" >/dev/null || { echo "sc-lint setup: root discovery failed or returned CLI.CONFIG_ERROR" >&2 @@ -166,7 +166,7 @@ runs: shell: pwsh run: | $root = if ($env:GITHUB_WORKSPACE) { $env:GITHUB_WORKSPACE } else { (Get-Location).Path } - $value = sc-lint --json --root $root lint sc-boundary | ConvertFrom-Json + $value = sc-lint --json --root $root lint sc-runtime | ConvertFrom-Json if (-not $value.ok -or $value.error.code -eq 'CLI.CONFIG_ERROR') { throw "sc-lint root discovery failed: $($value | ConvertTo-Json -Compress)" } diff --git a/boundaries/wyvern-host/host.toml b/boundaries/wyvern-host/host.toml index 37fb441b..1d4ca20a 100644 --- a/boundaries/wyvern-host/host.toml +++ b/boundaries/wyvern-host/host.toml @@ -5,10 +5,10 @@ name = "WyvernHost" [dependencies] allowed_dependencies = ["wyvern-schema", "serde", "serde_json", "axum", "tokio", "tower", "tower-http", "tracing", "rfd", "webbrowser", "dirs", "pulldown-cmark", "ammonia", "wyvern-wizard"] forbidden_edges = [ - "wyvern-host -> wyvern-cli", - "wyvern-host -> wyvern-mcp", - "wyvern-host -> wry", - "wyvern-host -> winit", + { from = "wyvern-host", to = "wyvern-cli" }, + { from = "wyvern-host", to = "wyvern-mcp" }, + { from = "wyvern-host", to = "wry" }, + { from = "wyvern-host", to = "winit" }, ] [ownership] diff --git a/boundaries/wyvern-mcp/mcp.toml b/boundaries/wyvern-mcp/mcp.toml index 9e1146e9..85606413 100644 --- a/boundaries/wyvern-mcp/mcp.toml +++ b/boundaries/wyvern-mcp/mcp.toml @@ -4,7 +4,10 @@ name = "WyvernMcp" [dependencies] allowed_dependencies = ["wyvern-schema", "wyvern-host", "tokio"] -forbidden_edges = ["wyvern-mcp -> wyvern-cli", "wyvern-mcp -> wyvern-wizard"] +forbidden_edges = [ + { from = "wyvern-mcp", to = "wyvern-cli" }, + { from = "wyvern-mcp", to = "wyvern-wizard" }, +] [ownership] io_owns = ["mcp_stdio_transport", "tool_registration", "persistent_host_lifecycle"] diff --git a/boundaries/wyvern-schema/schema.toml b/boundaries/wyvern-schema/schema.toml index 86487305..dd189842 100644 --- a/boundaries/wyvern-schema/schema.toml +++ b/boundaries/wyvern-schema/schema.toml @@ -5,12 +5,12 @@ name = "WyvernSchema" [dependencies] allowed_dependencies = ["serde", "serde_json", "strsim"] forbidden_edges = [ - "wyvern-schema -> wyvern-cli", - "wyvern-schema -> wyvern-wizard", - "wyvern-schema -> wyvern-mcp", - "wyvern-schema -> wry", - "wyvern-schema -> winit", - "wyvern-schema -> rfd", + { from = "wyvern-schema", to = "wyvern-cli" }, + { from = "wyvern-schema", to = "wyvern-wizard" }, + { from = "wyvern-schema", to = "wyvern-mcp" }, + { from = "wyvern-schema", to = "wry" }, + { from = "wyvern-schema", to = "winit" }, + { from = "wyvern-schema", to = "rfd" }, ] [ownership] diff --git a/boundaries/wyvern-viewer/viewer.toml b/boundaries/wyvern-viewer/viewer.toml index 8d5a1d52..7d4016fa 100644 --- a/boundaries/wyvern-viewer/viewer.toml +++ b/boundaries/wyvern-viewer/viewer.toml @@ -6,10 +6,10 @@ status = "active" [dependencies] allowed_dependencies = ["wry", "winit", "url", "tracing", "serde", "serde_json", "gtk"] forbidden_edges = [ - "wyvern-viewer -> wyvern", - "wyvern-viewer -> wyvern-mcp", - "wyvern-viewer -> wyvern-host", - "wyvern-viewer -> wyvern-schema", + { from = "wyvern-viewer", to = "wyvern" }, + { from = "wyvern-viewer", to = "wyvern-mcp" }, + { from = "wyvern-viewer", to = "wyvern-host" }, + { from = "wyvern-viewer", to = "wyvern-schema" }, ] [ownership] diff --git a/boundaries/wyvern-wizard/wizard.toml b/boundaries/wyvern-wizard/wizard.toml index 2c3b7bed..aed5ee8f 100644 --- a/boundaries/wyvern-wizard/wizard.toml +++ b/boundaries/wyvern-wizard/wizard.toml @@ -5,18 +5,18 @@ name = "WyvernWizard" [dependencies] allowed_dependencies = ["wyvern-schema", "serde_json"] forbidden_edges = [ - "wyvern-wizard -> wyvern", - "wyvern-wizard -> wyvern-window", - "wyvern-wizard -> wyvern-host", - "wyvern-wizard -> wyvern-mcp", - "wyvern-wizard -> wry", - "wyvern-wizard -> winit", - "wyvern-wizard -> rfd", - "wyvern-wizard -> axum", - "wyvern-wizard -> tokio", - "wyvern-wizard -> tower", - "wyvern-wizard -> hyper", - "wyvern-wizard -> reqwest", + { from = "wyvern-wizard", to = "wyvern" }, + { from = "wyvern-wizard", to = "wyvern-window" }, + { from = "wyvern-wizard", to = "wyvern-host" }, + { from = "wyvern-wizard", to = "wyvern-mcp" }, + { from = "wyvern-wizard", to = "wry" }, + { from = "wyvern-wizard", to = "winit" }, + { from = "wyvern-wizard", to = "rfd" }, + { from = "wyvern-wizard", to = "axum" }, + { from = "wyvern-wizard", to = "tokio" }, + { from = "wyvern-wizard", to = "tower" }, + { from = "wyvern-wizard", to = "hyper" }, + { from = "wyvern-wizard", to = "reqwest" }, ] [ownership] diff --git a/boundaries/wyvern/cli.toml b/boundaries/wyvern/cli.toml index a5c50629..06a0ed95 100644 --- a/boundaries/wyvern/cli.toml +++ b/boundaries/wyvern/cli.toml @@ -4,6 +4,7 @@ name = "WyvernCli" [dependencies] # wyvern-viewer: optional dep for dev binary-path helpers; embedded spawn uses subprocess (no wry in CLI) +allowed_dependents = [] allowed_dependencies = [ "wyvern-schema", "wyvern-host", @@ -21,10 +22,10 @@ allowed_dependencies = [ "libc", ] forbidden_edges = [ - "wyvern-cli -> wyvern-mcp", - "wyvern-cli -> wry", - "wyvern-cli -> winit", - "wyvern-cli -> rfd", + { from = "wyvern-cli", to = "wyvern-mcp" }, + { from = "wyvern-cli", to = "wry" }, + { from = "wyvern-cli", to = "winit" }, + { from = "wyvern-cli", to = "rfd" }, ] [ownership] diff --git a/scripts/check-boundaries.py b/scripts/check-boundaries.py index 707e00c7..bc600ceb 100755 --- a/scripts/check-boundaries.py +++ b/scripts/check-boundaries.py @@ -190,9 +190,13 @@ def check_one(boundary_path: Path) -> list[str]: allowed = set(deps.get("allowed_dependencies") or []) forbidden = set(deps.get("forbidden_dependencies") or []) for edge in deps.get("forbidden_edges") or []: - if "->" not in edge: + if isinstance(edge, dict): + src = str(edge.get("from", "")).strip() + dst = str(edge.get("to", "")).strip() + elif isinstance(edge, str) and "->" in edge: + src, dst = (part.strip() for part in edge.split("->", 1)) + else: continue - src, dst = (part.strip() for part in edge.split("->", 1)) if src == owner: forbidden.add(dst) if allowed or forbidden: From fd1458896c070e492669168300afc09f7f7d6594 Mon Sep 17 00:00:00 2001 From: Rand Lee Date: Thu, 27 Aug 2026 21:42:07 -0700 Subject: [PATCH 12/12] fix(release): tighten preflight publish-plan for first crates.io release Filter list-publish-plan to publish=true crates in publish_order, and validate only the first crate during preflight (later crates need deps live on crates.io). Update first-release-record with remediation log. Co-authored-by: Cursor --- .github/scripts/release_artifacts.py | 6 ++- .github/workflows/release-preflight.yml | 16 ++++++-- .../.plan-hardening/first-release-record.md | 38 ++++++++++++------- 3 files changed, 43 insertions(+), 17 deletions(-) diff --git a/.github/scripts/release_artifacts.py b/.github/scripts/release_artifacts.py index 6af552c3..5acabac4 100644 --- a/.github/scripts/release_artifacts.py +++ b/.github/scripts/release_artifacts.py @@ -417,7 +417,11 @@ def cmd_validate_manifest(args: argparse.Namespace) -> int: def cmd_list_publish_plan(args: argparse.Namespace) -> int: manifest = load_manifest(Path(args.manifest)) - for crate in manifest["crates"]: + publishable = sorted( + (crate for crate in manifest["crates"] if crate.get("publish", True)), + key=lambda crate: crate.get("publish_order", 0), + ) + for crate in publishable: print(f"{crate['package']}|{crate['wait_after_publish_seconds']}") return 0 diff --git a/.github/workflows/release-preflight.yml b/.github/workflows/release-preflight.yml index f2d780bb..73b6c116 100644 --- a/.github/workflows/release-preflight.yml +++ b/.github/workflows/release-preflight.yml @@ -378,9 +378,19 @@ jobs: shell: bash run: | set -euo pipefail - while IFS='|' read -r package _; do - cargo package -p "$package" --locked --allow-dirty - done < <(python3 .github/scripts/release_artifacts.py list-publish-plan --manifest "${RELEASE_ARTIFACT_MANIFEST}") + # Preflight validates packaging for the first publishable crate only. + # Later crates depend on prior publishes being live on crates.io and are + # verified during ordered release.yml publication. + mapfile -t publish_plan < <( + python3 .github/scripts/release_artifacts.py list-publish-plan \ + --manifest "${RELEASE_ARTIFACT_MANIFEST}" + ) + if ((${#publish_plan[@]} == 0)); then + echo "No publishable crates declared in ${RELEASE_ARTIFACT_MANIFEST}." + exit 0 + fi + package="${publish_plan[0]%%|*}" + cargo package -p "${package}" --locked --allow-dirty - id: github_release_permissions name: Verify GitHub Release workflow permissions diff --git a/docs/plans/phase-J/.plan-hardening/first-release-record.md b/docs/plans/phase-J/.plan-hardening/first-release-record.md index b97d4b91..6358039a 100644 --- a/docs/plans/phase-J/.plan-hardening/first-release-record.md +++ b/docs/plans/phase-J/.plan-hardening/first-release-record.md @@ -1,29 +1,41 @@ # First kit-managed release record (j.3) -**Status:** pending -**Target version:** `0.6.0` (TBD at cut time) -**Branch:** `integrate/phase-J` → `develop` → `main` +**Status:** preflight blocked on `WINGET_GITHUB_TOKEN` refresh +**Target version:** `0.6.0` +**Release PR:** [#149](https://github.com/randlee/wyvern/pull/149) (`release/v0.6.0` → `main`) -Fill this during j.3 execution. j.4 go/no-go reads the final row. +See [j3-rc-runbook.md](j3-rc-runbook.md) for dispatch commands after #148 merges. ## Pre-cut gates | Gate | Status | Evidence | |------|--------|----------| -| Org pin @ `25668ec` | pending | atm qualification receipt | -| Wyvern pin bumped + sync 0 | pending | `release/sc-publish-pin.toml` | -| CR-001/002 resolved | pending | upstream-tracking | +| Org pin @ `25668ec` | **done** | atm #1069 + wyvern #146 | +| Wyvern pin bumped + sync 0 | **done** | `develop` pin @ `25668ec` | +| CR-001/002 resolved | **done** | upstream-tracking | | Winget bootstrap submitted | **submitted** | [winget-pkgs #425477](https://github.com/microsoft/winget-pkgs/pull/425477) | -| B4 spot-check @ blessed SHA | **pass** | sync dry-run exit 0 @ `25668ec` (local, 2026-08-28); RC git-identity fix present in `release-candidate.yml` | +| B4 spot-check @ blessed SHA | **pass** | sync @ `25668ec` | +| RC workflow dispatchable | **done** | PR #148 merged; RC [33140961859](https://github.com/randlee/wyvern/actions/runs/33140961859) success | -## State machine +## Preflight remediation log + +| Run | Result | Remaining blocker | +|-----|--------|-------------------| +| [33141018872](https://github.com/randlee/wyvern/actions/runs/33141018872) | failed | WINGET 401, crates_io liveness kind, test flake | +| [33141348678](https://github.com/randlee/wyvern/actions/runs/33141348678) | failed | sc-lint smoke, WINGET 401 | +| [33141760349](https://github.com/randlee/wyvern/actions/runs/33141760349) | failed | sc-lint boundary schema | +| [33142179164](https://github.com/randlee/wyvern/actions/runs/33142179164) | failed | **WINGET 401**, wyvern-mcp package check | + +**Fixed on `release/v0.6.0` @ `277d75c`+:** sc-lint smoke (`sc-runtime`), boundary TOML, crates_io liveness contract, test flake retry, publish-plan ordering, preflight package smoke (first crate only). + +**Operator action required:** refresh `WINGET_GITHUB_TOKEN` on `randlee/wyvern` (401 from `api.github.com/user`). Classic or fine-grained PAT with fork/PR rights to `microsoft/winget-pkgs`. | Step | Workflow | Run ID | SHA/tag | Result | |------|----------|--------|---------|--------| -| RC dispatch | `release-candidate.yml` | | `release-candidate-vX.Y.Z` | | -| Release branch merge | PR → `main` | | `release/vX.Y.Z` | | -| Preflight | `release-preflight.yml` | | exact `main` SHA | | -| Production | `release.yml` | | `vX.Y.Z` | | +| RC dispatch | `release-candidate.yml` | [33140961859](https://github.com/randlee/wyvern/actions/runs/33140961859) | `release-candidate-v0.6.0` | **success** | +| Readiness preflight | `release-preflight.yml` | [33142179164](https://github.com/randlee/wyvern/actions/runs/33142179164) | `release/v0.6.0` | **failed** — WINGET token | +| Release branch merge | PR → `main` | [#149](https://github.com/randlee/wyvern/pull/149) | `release/v0.6.0` | open | +| Production | `release.yml` | | `v0.6.0` | pending preflight green | ## Channel outcomes