From 993ac3547bdcc150b985357cc7fbf58d9c2921fc Mon Sep 17 00:00:00 2001 From: Rand Lee Date: Thu, 27 Aug 2026 13:22:41 -0700 Subject: [PATCH 01/22] 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 --- .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 | 11 + .cursor/agents/publisher.md | 95 + .cursor/commands/cursor-publish.md | 34 + .cursor/skills/cursor-publish/SKILL.md | 89 + .../extract-published-renderer/action.yml | 37 + .../actions/setup-lint-toolchain/action.yml | 46 + .../setup-python-release-build/action.yml | 61 + .github/actions/setup-sc-lint/action.yml | 172 + .../verify-published-release/action.yml | 111 + .github/scripts/bootstrap_sc_compose.py | 94 + .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 | 436 +++ .../scripts/tests/test_publish_kit_assets.py | 94 + .../scripts/tests/test_publish_kit_scripts.py | 364 ++ .../scripts/tests/test_release_artifacts.py | 3320 +++++++++++++++++ .github/workflows/crates-publish.yml | 110 + .github/workflows/homebrew-publish.yml | 267 ++ .github/workflows/pypi-publish.yml | 131 + .github/workflows/release-candidate.yml | 65 + .github/workflows/release-preflight.yml | 502 ++- .../workflows/release-retry-distribution.yml | 141 - .github/workflows/release.yml | 685 ++-- .github/workflows/scoop-publish.yml | 181 + .github/workflows/winget-publish.yml | 114 + README.sc-publish.md | 85 + .../plans/phase-J/j1-vendor-sc-publish-kit.md | 2 +- release/publish-artifacts.toml | 165 +- release/publish-artifacts.toml.j2 | 177 + release/publish-channel-contracts.toml | 63 + release/publish-channel-contracts.toml.j2 | 63 + scripts/release_artifacts.py | 501 --- scripts/release_gate.sh | 47 - scripts/sync-sc-publish.sh | 2 +- 55 files changed, 10307 insertions(+), 1531 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/setup-lint-toolchain/action.yml create mode 100644 .github/actions/setup-python-release-build/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 release/publish-artifacts.toml.j2 create mode 100644 release/publish-channel-contracts.toml create mode 100644 release/publish-channel-contracts.toml.j2 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..4917d169 --- /dev/null +++ b/.claude/skills/publishing/ref/renderer-contract.md @@ -0,0 +1,11 @@ +# 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`. 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/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-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..3f2f3b25 --- /dev/null +++ b/.github/scripts/bootstrap_sc_compose.py @@ -0,0 +1,94 @@ +#!/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 main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--venv", required=True, type=Path, help="managed virtual environment") + 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) + 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..a6312e60 --- /dev/null +++ b/.github/scripts/tests/test_install.py @@ -0,0 +1,436 @@ +"""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('git tag -a "${candidate_tag}" origin/develop', 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("git tag -a \"${candidate_tag}\" origin/develop", 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..b10e1326 --- /dev/null +++ b/.github/scripts/tests/test_publish_kit_scripts.py @@ -0,0 +1,364 @@ +"""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) + + 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_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..a7bb2af5 --- /dev/null +++ b/.github/scripts/tests/test_release_artifacts.py @@ -0,0 +1,3320 @@ +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"}, + } + 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", + "setup-lint-toolchain", + "setup-python-release-build", + "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/extract-published-renderer" 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/extract-published-renderer" in homebrew_text + assert "install_block" not in homebrew_text + assert "bundled_paths" in homebrew_text + + renderer_action = ( + repo_root() + / ".github" + / "actions" + / "extract-published-renderer" + / "action.yml" + ).read_text(encoding="utf-8") + assert "binary-path" in renderer_action + assert "Published renderer archive is missing ${RENDERER_BINARY_PATH}" in renderer_action + assert "renderer-path=${renderer}" in renderer_action + + +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..5bafc0f1 --- /dev/null +++ b/.github/workflows/crates-publish.yml @@ -0,0 +1,110 @@ +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: 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..e9fdbe6e --- /dev/null +++ b/.github/workflows/homebrew-publish.yml @@ -0,0 +1,267 @@ +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: 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"] + channel = config["channel"] + assets = {} + archives_by_target = {} + + 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, + ) + archives_by_target[target["target"]] = path + 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" + ) + renderer_target = config["release_targets"][channel["renderer_target"]] + renderer_archive = archives_by_target.get(renderer_target["target"]) + if renderer_archive is None: + _url, renderer_archive = download(renderer_target) + Path("homebrew-release-assets.json").write_text(json.dumps(assets), encoding="utf-8") + with Path(os.environ["GITHUB_ENV"]).open("a", encoding="utf-8") as env_file: + env_file.write(f"PUBLISHED_RENDERER_ARCHIVE={renderer_archive}\n") + env_file.write( + "PUBLISHED_RENDERER_EXTRACT_DIR=" + f"{project['archive_prefix']}_{os.environ['RELEASE_VERSION']}_{renderer_target['target']}\n" + ) + PY + - name: Extract manifest-declared published renderer + uses: ./.github/actions/extract-published-renderer + with: + archive: ${{ env.PUBLISHED_RENDERER_ARCHIVE }} + extract-dir: ${{ env.PUBLISHED_RENDERER_EXTRACT_DIR }} + binary-path: ${{ fromJSON(needs.verify-release.outputs.channel_config).project.renderer_archive_path }} + - 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..8b0f8390 --- /dev/null +++ b/.github/workflows/release-candidate.yml @@ -0,0 +1,65 @@ +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 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..d0b0abb7 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,174 @@ 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: Normalize version input id: meta + continue-on-error: true shell: bash run: | set -euo pipefail @@ -74,30 +225,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..2680f1e3 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,511 @@ 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: cargo audit - run: cargo audit + - 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: Install cargo-deny - run: cargo install cargo-deny --locked + 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: cargo deny check - run: cargo deny check + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@master + with: + toolchain: ${{ needs.release-plan.outputs.rust_toolchain }} + targets: ${{ matrix.target }} - - name: Enforce boundaries - run: python3 scripts/check-boundaries.py + - name: Ensure cross-compilation target + run: rustup target add ${{ matrix.target }} - - 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: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.x" - - name: Publish crates in dependency order (idempotent) - shell: bash + - 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 }} - - # 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: python-wheel-${{ matrix.artifact }}-${{ matrix.os }} + path: dist/*.whl - - 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..cf0fdaf0 --- /dev/null +++ b/.github/workflows/scoop-publish.yml @@ -0,0 +1,181 @@ +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: 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) + renderer_target = config["release_targets"][channel["renderer_target"]] + _renderer_url, renderer_archive = download(renderer_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") + with Path(os.environ["GITHUB_ENV"]).open("a", encoding="utf-8") as env_file: + env_file.write(f"PUBLISHED_RENDERER_ARCHIVE={renderer_archive}\n") + env_file.write( + "PUBLISHED_RENDERER_EXTRACT_DIR=" + f"{project['archive_prefix']}_{os.environ['RELEASE_VERSION']}_{renderer_target['target']}\n" + ) + PY + - name: Extract manifest-declared published renderer + uses: ./.github/actions/extract-published-renderer + with: + archive: ${{ env.PUBLISHED_RENDERER_ARCHIVE }} + extract-dir: ${{ env.PUBLISHED_RENDERER_EXTRACT_DIR }} + binary-path: ${{ fromJSON(needs.verify-release.outputs.channel_config).project.renderer_archive_path }} + - 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/README.sc-publish.md b/README.sc-publish.md new file mode 100644 index 00000000..e67dbb95 --- /dev/null +++ b/README.sc-publish.md @@ -0,0 +1,85 @@ +# 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. + +## 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`. + +## 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/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/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/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..3b5a9c2c 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" --dry-run --input "${input}" "${repo_root}" "${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) (pin ${SC_PUBLISH_REF})" From de85cf756f52f79b5eb0556848d917c4be8b3522 Mon Sep 17 00:00:00 2001 From: Rand Lee Date: Thu, 27 Aug 2026 18:55:33 -0700 Subject: [PATCH 02/22] 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 --- .../publishing/ref/channel-contracts.md | 4 +- .../skills/publishing/ref/org-destinations.md | 32 ++++++ .../publishing/ref/renderer-contract.md | 3 + .../install-linux-native-deps/action.yml | 21 ++++ .github/actions/setup-renderer/action.yml | 24 +++++ .github/scripts/bootstrap_sc_compose.py | 55 ++++++++++ .github/scripts/tests/test_install.py | 88 +++++++++------ .../scripts/tests/test_publish_kit_scripts.py | 14 +++ .../scripts/tests/test_release_artifacts.py | 29 ++++- .github/workflows/crates-publish.yml | 3 + .github/workflows/homebrew-publish.yml | 21 +--- .github/workflows/release-preflight.yml | 4 + .github/workflows/release.yml | 6 ++ .github/workflows/scoop-publish.yml | 16 +-- .gitignore | 1 + README.md | 14 +-- README.sc-publish.md | 13 ++- docs/RELEASE_SECRETS.md | 100 +++++++++++++++--- docs/SCOOP_SETUP.md | 99 +++++++++++++++++ docs/WINGET_SETUP.md | 13 ++- .../.plan-hardening/upstream-tracking.md | 44 +++++++- .../phase-J/j2-upstream-blockers-and-docs.md | 2 +- .../phase-J/publish-architecture-decision.md | 5 + release/org-destinations.toml | 19 ++++ scripts/sync-sc-publish.sh | 4 +- 25 files changed, 532 insertions(+), 102 deletions(-) create mode 100644 .claude/skills/publishing/ref/org-destinations.md create mode 100644 .github/actions/install-linux-native-deps/action.yml create mode 100644 .github/actions/setup-renderer/action.yml create mode 100644 docs/SCOOP_SETUP.md create mode 100644 release/org-destinations.toml diff --git a/.claude/skills/publishing/ref/channel-contracts.md b/.claude/skills/publishing/ref/channel-contracts.md index 63b3e1c9..b37cf0b0 100644 --- a/.claude/skills/publishing/ref/channel-contracts.md +++ b/.claude/skills/publishing/ref/channel-contracts.md @@ -2,7 +2,9 @@ `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 +and liveness checks. **`release/org-destinations.toml`** defines mandatory +org-wide publish destinations and required channels; see +`ref/org-destinations.md`. This document defines only the operating procedure for using that contract. Copy both files unchanged when vendoring the publish kit. ## Common rules diff --git a/.claude/skills/publishing/ref/org-destinations.md b/.claude/skills/publishing/ref/org-destinations.md new file mode 100644 index 00000000..0c54555b --- /dev/null +++ b/.claude/skills/publishing/ref/org-destinations.md @@ -0,0 +1,32 @@ +# Org publish destinations + +`release/org-destinations.toml` is vendored **byte-for-byte** with the publish kit. +It is the sole authority for: + +1. **Required channels** — every entry in `required_channels` must appear in + `release/install.json` → `channels`. Agents **must not** omit them when + bootstrapping or migrating a consumer repo. +2. **Fixed destinations** — `tap_repository`, `bucket_repository`, and the + winget `publisher_id` prefix are **not overridable** in `install.json`. + Wrong values fail `install.py`; omitted Homebrew/Scoop destination fields + are injected from this file at sync time. + +## Agent rules + +- Read `release/org-destinations.toml` before authoring or editing + `release/install.json`. +- Never propose a per-repo Homebrew tap, Scoop bucket, or non-`randlee.*` winget + identifier to “simplify” setup. +- Product-specific slots remain in `install.json`: formula path, Scoop manifest + path, winget package name suffix, crate list, binaries, bundled paths. +- PyPI and other channels not listed in `required_channels` stay opt-in per repo. + +## Verification + +```bash +python plugins/sc-publish/install.py --input release/install.json --dry-run . +``` + +Sync fails closed when mandatory channels or destinations drift. + +See also: `release/publish-channel-contracts.toml`, `ref/channel-contracts.md`. diff --git a/.claude/skills/publishing/ref/renderer-contract.md b/.claude/skills/publishing/ref/renderer-contract.md index 4917d169..3e7ab897 100644 --- a/.claude/skills/publishing/ref/renderer-contract.md +++ b/.claude/skills/publishing/ref/renderer-contract.md @@ -9,3 +9,6 @@ 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/.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-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/scripts/bootstrap_sc_compose.py b/.github/scripts/bootstrap_sc_compose.py index 3f2f3b25..487a6457 100644 --- a/.github/scripts/bootstrap_sc_compose.py +++ b/.github/scripts/bootstrap_sc_compose.py @@ -76,9 +76,62 @@ def provision_pinned_wheel(python: Path) -> None: 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) @@ -86,6 +139,8 @@ def main() -> int: 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 diff --git a/.github/scripts/tests/test_install.py b/.github/scripts/tests/test_install.py index a6312e60..58668c43 100644 --- a/.github/scripts/tests/test_install.py +++ b/.github/scripts/tests/test_install.py @@ -106,7 +106,7 @@ def valid_values() -> dict[str, object]: "homebrew": { "workflow": "homebrew-publish.yml", "dispatch_inputs": {}, - "tap_repository": "example/tap", + "tap_repository": "randlee/homebrew-tap", "renderer_target": "x86_64-unknown-linux-gnu", "formulas": [ { @@ -129,13 +129,13 @@ def valid_values() -> dict[str, object]: "winget": { "workflow": "winget-publish.yml", "dispatch_inputs": {}, - "identifier": "example.example", + "identifier": "randlee.example", "installer_target": "x86_64-pc-windows-msvc", }, "scoop": { "workflow": "scoop-publish.yml", "dispatch_inputs": {}, - "bucket_repository": "example/scoop-bucket", + "bucket_repository": "randlee/scoop-bucket", "manifest_path": "bucket/example.json", "manifest_template": "release/scoop/manifest.json.j2", "installer_target": "x86_64-pc-windows-msvc", @@ -199,22 +199,63 @@ def test_load_install_values_rejects_missing_complete_contract_fields(self) -> N with self.assertRaisesRegex(Exception, "project.license"): INSTALL.load_install_values(path) - def test_load_install_values_accepts_a_channel_subset(self) -> None: + def test_load_install_values_rejects_omitted_mandatory_channels(self) -> None: + values = self.valid_values() + del values["channels"]["scoop"] + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "input.json" + path.write_text(json.dumps(values), encoding="utf-8") + with self.assertRaisesRegex(Exception, "mandatory publish destinations"): + INSTALL.load_install_values(path) + + def test_load_install_values_rejects_non_org_homebrew_tap(self) -> None: + values = self.valid_values() + values["channels"]["homebrew"]["tap_repository"] = "other/homebrew-tap" + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "input.json" + path.write_text(json.dumps(values), encoding="utf-8") + with self.assertRaisesRegex(Exception, "org-destinations.toml"): + INSTALL.load_install_values(path) + + def test_load_install_values_rejects_non_org_scoop_bucket(self) -> None: + values = self.valid_values() + values["channels"]["scoop"]["bucket_repository"] = "other/scoop-bucket" + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "input.json" + path.write_text(json.dumps(values), encoding="utf-8") + with self.assertRaisesRegex(Exception, "org-destinations.toml"): + INSTALL.load_install_values(path) + + def test_load_install_values_rejects_non_org_winget_identifier(self) -> None: + values = self.valid_values() + values["channels"]["winget"]["identifier"] = "other.example" + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "input.json" + path.write_text(json.dumps(values), encoding="utf-8") + with self.assertRaisesRegex(Exception, "publisher_id"): + INSTALL.load_install_values(path) + + def test_load_install_values_injects_org_destinations_when_omitted(self) -> None: + values = self.valid_values() + del values["channels"]["homebrew"]["tap_repository"] + del values["channels"]["scoop"]["bucket_repository"] + 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(loaded["channels"]["homebrew"]["tap_repository"], "randlee/homebrew-tap") + self.assertEqual(loaded["channels"]["scoop"]["bucket_repository"], "randlee/scoop-bucket") + + def test_load_install_values_accepts_pypi_only_when_not_in_required_channels(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"]) + with self.assertRaisesRegex(Exception, "mandatory publish destinations"): + INSTALL.load_install_values(path) def test_load_install_values_rejects_unknown_channel_names(self) -> None: values = self.valid_values() @@ -225,30 +266,15 @@ def test_load_install_values_rejects_unknown_channel_names(self) -> None: 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") + def test_load_install_values_allows_omitted_optional_pypi(self) -> None: values = self.valid_values() - for name in ("homebrew", "winget", "scoop"): - del values["channels"][name] - del values["project"]["renderer_archive_path"] + del values["channels"]["pypi"] 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"]) + self.assertNotIn("pypi", loaded["channels"]) + self.assertIn("homebrew", loaded["channels"]) def test_load_install_values_rejects_ambiguous_python_distribution(self) -> None: values = self.valid_values() diff --git a/.github/scripts/tests/test_publish_kit_scripts.py b/.github/scripts/tests/test_publish_kit_scripts.py index b10e1326..6fe8dca8 100644 --- a/.github/scripts/tests/test_publish_kit_scripts.py +++ b/.github/scripts/tests/test_publish_kit_scripts.py @@ -279,6 +279,8 @@ def test_bootstrap_enforces_the_exact_documented_renderer_version(self) -> None: 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( @@ -313,6 +315,18 @@ def test_bootstrap_does_not_reinstall_the_exact_pinned_wheel(self) -> None: 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] diff --git a/.github/scripts/tests/test_release_artifacts.py b/.github/scripts/tests/test_release_artifacts.py index a7bb2af5..7bf116c4 100644 --- a/.github/scripts/tests/test_release_artifacts.py +++ b/.github/scripts/tests/test_release_artifacts.py @@ -936,6 +936,8 @@ def test_no_single_repo_concerns_leak_into_kit_workflows_actions_or_scripts() -> "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", @@ -949,8 +951,10 @@ def test_no_single_repo_concerns_leak_into_kit_workflows_actions_or_scripts() -> ) kit_actions = ( "extract-published-renderer", + "install-linux-native-deps", "setup-lint-toolchain", "setup-python-release-build", + "setup-renderer", "setup-sc-lint", "verify-published-release", ) @@ -2419,7 +2423,8 @@ def test_channel_recovery_workflows_require_a_published_release() -> None: 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/extract-published-renderer" 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 @@ -2429,7 +2434,8 @@ def test_channel_recovery_workflows_require_a_published_release() -> None: 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/extract-published-renderer" 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 @@ -2437,13 +2443,26 @@ def test_channel_recovery_workflows_require_a_published_release() -> None: repo_root() / ".github" / "actions" - / "extract-published-renderer" + / "setup-renderer" / "action.yml" ).read_text(encoding="utf-8") - assert "binary-path" in renderer_action - assert "Published renderer archive is missing ${RENDERER_BINARY_PATH}" in renderer_action + 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] diff --git a/.github/workflows/crates-publish.yml b/.github/workflows/crates-publish.yml index 5bafc0f1..86d7624e 100644 --- a/.github/workflows/crates-publish.yml +++ b/.github/workflows/crates-publish.yml @@ -57,6 +57,9 @@ jobs: 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: diff --git a/.github/workflows/homebrew-publish.yml b/.github/workflows/homebrew-publish.yml index e9fdbe6e..1d03e629 100644 --- a/.github/workflows/homebrew-publish.yml +++ b/.github/workflows/homebrew-publish.yml @@ -67,6 +67,8 @@ jobs: 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: @@ -96,9 +98,7 @@ jobs: config = json.loads(os.environ["CHANNEL_CONFIG"]) project = config["project"] - channel = config["channel"] assets = {} - archives_by_target = {} def download(target): filename = f"{project['archive_prefix']}_{os.environ['RELEASE_VERSION']}_{target['target']}.{target['archive']}" @@ -108,7 +108,6 @@ jobs: ["curl", "-fsSL", "--retry", "5", "--retry-delay", "30", "--retry-all-errors", "-o", str(path), url], check=True, ) - archives_by_target[target["target"]] = path return url, path for asset in config["channel"]["assets"]: @@ -125,24 +124,8 @@ jobs: raise SystemExit( "Homebrew assets must contain exactly: macos_arm, macos_intel, linux" ) - renderer_target = config["release_targets"][channel["renderer_target"]] - renderer_archive = archives_by_target.get(renderer_target["target"]) - if renderer_archive is None: - _url, renderer_archive = download(renderer_target) Path("homebrew-release-assets.json").write_text(json.dumps(assets), encoding="utf-8") - with Path(os.environ["GITHUB_ENV"]).open("a", encoding="utf-8") as env_file: - env_file.write(f"PUBLISHED_RENDERER_ARCHIVE={renderer_archive}\n") - env_file.write( - "PUBLISHED_RENDERER_EXTRACT_DIR=" - f"{project['archive_prefix']}_{os.environ['RELEASE_VERSION']}_{renderer_target['target']}\n" - ) PY - - name: Extract manifest-declared published renderer - uses: ./.github/actions/extract-published-renderer - with: - archive: ${{ env.PUBLISHED_RENDERER_ARCHIVE }} - extract-dir: ${{ env.PUBLISHED_RENDERER_EXTRACT_DIR }} - binary-path: ${{ fromJSON(needs.verify-release.outputs.channel_config).project.renderer_archive_path }} - name: Render manifest-selected formulas with the published renderer shell: bash env: diff --git a/.github/workflows/release-preflight.yml b/.github/workflows/release-preflight.yml index d0b0abb7..f2d780bb 100644 --- a/.github/workflows/release-preflight.yml +++ b/.github/workflows/release-preflight.yml @@ -212,6 +212,10 @@ jobs: 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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2680f1e3..826086a5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -153,6 +153,9 @@ jobs: - name: Ensure cross-compilation target run: rustup target add ${{ matrix.target }} + - name: Install Linux native build deps + uses: ./.github/actions/install-linux-native-deps + - name: Cache cargo uses: actions/cache@v4 with: @@ -248,6 +251,9 @@ jobs: with: toolchain: ${{ needs.release-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: diff --git a/.github/workflows/scoop-publish.yml b/.github/workflows/scoop-publish.yml index cf0fdaf0..d10ea7b8 100644 --- a/.github/workflows/scoop-publish.yml +++ b/.github/workflows/scoop-publish.yml @@ -68,6 +68,8 @@ jobs: 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: @@ -110,8 +112,6 @@ jobs: target = config["release_targets"][channel["installer_target"]] url, archive = download(target) - renderer_target = config["release_targets"][channel["renderer_target"]] - _renderer_url, renderer_archive = download(renderer_target) variables = { "version": os.environ["RELEASE_VERSION"], "description": project["description"], @@ -123,19 +123,7 @@ jobs: "binary": channel["binary"], } Path("scoop-vars.json").write_text(json.dumps(variables), encoding="utf-8") - with Path(os.environ["GITHUB_ENV"]).open("a", encoding="utf-8") as env_file: - env_file.write(f"PUBLISHED_RENDERER_ARCHIVE={renderer_archive}\n") - env_file.write( - "PUBLISHED_RENDERER_EXTRACT_DIR=" - f"{project['archive_prefix']}_{os.environ['RELEASE_VERSION']}_{renderer_target['target']}\n" - ) PY - - name: Extract manifest-declared published renderer - uses: ./.github/actions/extract-published-renderer - with: - archive: ${{ env.PUBLISHED_RENDERER_ARCHIVE }} - extract-dir: ${{ env.PUBLISHED_RENDERER_EXTRACT_DIR }} - binary-path: ${{ fromJSON(needs.verify-release.outputs.channel_config).project.renderer_archive_path }} - name: Render Scoop manifest with published renderer shell: bash env: diff --git a/.gitignore b/.gitignore index 3b30380d..e77594ed 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,4 @@ tests/e2e/playwright-report/ tests/l2/node_modules/ tests/l2/test-results/ tests/l2/playwright-report/ +install.py 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 index e67dbb95..6de2c09d 100644 --- a/README.sc-publish.md +++ b/README.sc-publish.md @@ -30,8 +30,12 @@ python plugins/sc-publish/.github/scripts/bootstrap_sc_compose.py --venv 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 — +binaries, Python distributions, and **product slots** within org-wide publish +destinations (formula path, Scoop manifest path, winget identifier suffix). +**Mandatory channels and shared tap/bucket/winget targets** come from the +vendored `release/org-destinations.toml`; `install.py` rejects omitted required +channels and non-org destination overrides. Only two files are rendered from the +JSON input — `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 @@ -73,6 +77,11 @@ Each publish channel — `github_release`, `crates_io`, `pypi`, `homebrew`, - 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 diff --git a/docs/RELEASE_SECRETS.md b/docs/RELEASE_SECRETS.md index 4d2053b0..9dc002c0 100644 --- a/docs/RELEASE_SECRETS.md +++ b/docs/RELEASE_SECRETS.md @@ -1,22 +1,78 @@ # 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`). + +**Enforcement:** vendored `release/org-destinations.toml` (from sc-publish kit) +lists `required_channels` and fixed destinations. `install.py` **fails closed** +if an agent omits a required channel or overrides a destination; omitted +Homebrew/Scoop destination fields are injected at sync time. Agents must not +opt out of this file when bootstrapping consumers. + +**sc-publish upstream:** same file ships in the kit; change org policy upstream +only, then re-sync — never hand-edit in consumer repos. ## 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 +86,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/upstream-tracking.md b/docs/plans/phase-J/.plan-hardening/upstream-tracking.md index 2608043b..6847c877 100644 --- a/docs/plans/phase-J/.plan-hardening/upstream-tracking.md +++ b/docs/plans/phase-J/.plan-hardening/upstream-tracking.md @@ -2,15 +2,51 @@ 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 [PR #61](https://github.com/randlee/sc-publish/pull/61) merged → `main` @ `43552e4c9e6d3435ed58a4a7eca42dd82f7edb74` | | Kit jobs install webkit/wayland apt packages via `.github/actions/install-linux-native-deps`. | +| CR-002 Homebrew/Scoop renderer | **resolved** | same merge as CR-001 | | `homebrew-publish.yml` and `scoop-publish.yml` bootstrap the pinned renderer via `.github/actions/setup-renderer` (not the product binary). `release/install.json` keeps non-empty `renderer_archive_path` = `bin/wyvern`. | **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`. +- This closeout does **not** sign waivers. + +## Disposition notes + +### CR-001 + +Kit composite action `.github/actions/install-linux-native-deps` installs +`libwebkit2gtk-4.1-dev`, `libwayland-dev`, and the matching runtime/Xvfb +packages. Wired into: + +- `release.yml` build matrix (Linux no-op on macOS/Windows) +- `release.yml` crates publish job +- `release-preflight.yml` (when the manifest has crates) +- `crates-publish.yml` + +### CR-002 + +Both channel workflows call `.github/actions/setup-renderer`, which runs +`bootstrap_sc_compose.py --write-cli` and exports `PUBLISHED_RENDERER`. They no +longer extract `project.renderer_archive_path` from the Linux product archive. +`renderer_archive_path` remains required by the kit schema while Scoop/Homebrew +are declared; Wyvern keeps `bin/wyvern` (archive binary path, not the renderer). + +Wyvern is re-synced to sc-publish `43552e4` via `scripts/sync-sc-publish.sh`. + +## j.2 closeout extras + +| Item | Status | +|------|--------| +| `WINGET_GITHUB_TOKEN` | Present in `gh secret list` (shared org PAT — not created in j.2) | +| `SCOOP_BUCKET_TOKEN` | Present in `gh secret list` (shared org PAT — not created in j.2) | +| `randlee/scoop-bucket` | Public, cloneable: https://github.com/randlee/scoop-bucket ; `bucket/` has only `.gitkeep` (workflow seeds `bucket/wyvern.json`) | +| Scoop push probe | Authenticated `gh api repos/randlee/scoop-bucket` as `randlee` reports `permissions.push=true` | +| `randlee.wyvern` in `winget-pkgs` | **Absent** (`manifests/r/randlee/wyvern` 404). Owner bootstrap **before j.3**. | +| `homebrew_destination_components` | `["share","wyvern","ui"]` in `release/install.json` | +| `scripts/validate_release.py` | Deleted (already absent on this branch) | Updated in j.2 sprint closeout. 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..84e1001a 100644 --- a/docs/plans/phase-J/publish-architecture-decision.md +++ b/docs/plans/phase-J/publish-architecture-decision.md @@ -38,6 +38,11 @@ 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 diff --git a/release/org-destinations.toml b/release/org-destinations.toml new file mode 100644 index 00000000..aa51c0ac --- /dev/null +++ b/release/org-destinations.toml @@ -0,0 +1,19 @@ +# Org-wide publish destinations — vendored byte-for-byte into every kit consumer. +# install.py enforces this file: required channels must be declared in +# release/install.json, and destination fields must match (no overrides). +# Change upstream in sc-publish only; consumer hand-edits are defects. + +schema_version = 1 + +# Post-release channels every consumer MUST declare. Omitting any entry fails sync. +required_channels = ["homebrew", "scoop", "winget"] + +[destinations.homebrew] +tap_repository = "randlee/homebrew-tap" + +[destinations.scoop] +bucket_repository = "randlee/scoop-bucket" + +[destinations.winget] +publisher_id = "randlee" +pkgs_upstream = "microsoft/winget-pkgs" diff --git a/scripts/sync-sc-publish.sh b/scripts/sync-sc-publish.sh index 3b5a9c2c..4dbb31fc 100755 --- a/scripts/sync-sc-publish.sh +++ b/scripts/sync-sc-publish.sh @@ -16,8 +16,8 @@ if [[ ! -d "${sc_publish_root}/.git" ]]; then exit 1 fi -SC_PUBLISH_REF="${SC_PUBLISH_REF:-6aace27}" -SC_PUBLISH_EXPECTED_SHA="${SC_PUBLISH_EXPECTED_SHA:-6aace27b78aa6487c9185d831e1ae70f407fded9}" +SC_PUBLISH_REF="${SC_PUBLISH_REF:-0d37ed57c942f3f7e50c030566df25b7b009ed39}" +SC_PUBLISH_EXPECTED_SHA="${SC_PUBLISH_EXPECTED_SHA:-0d37ed57c942f3f7e50c030566df25b7b009ed39}" ( cd "${sc_publish_root}" From fdc43ceb5ef12ca5ad834037265752e8debed837 Mon Sep 17 00:00:00 2001 From: Rand Date: Thu, 27 Aug 2026 19:15:38 -0700 Subject: [PATCH 03/22] 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 --- .../publishing/ref/channel-contracts.md | 4 +- .../skills/publishing/ref/org-destinations.md | 32 ------- .github/scripts/tests/test_install.py | 88 +++++++------------ README.sc-publish.md | 8 +- docs/RELEASE_SECRETS.md | 12 +-- release/org-destinations.toml | 19 ---- scripts/sync-sc-publish.sh | 4 +- 7 files changed, 40 insertions(+), 127 deletions(-) delete mode 100644 .claude/skills/publishing/ref/org-destinations.md delete mode 100644 release/org-destinations.toml diff --git a/.claude/skills/publishing/ref/channel-contracts.md b/.claude/skills/publishing/ref/channel-contracts.md index b37cf0b0..63b3e1c9 100644 --- a/.claude/skills/publishing/ref/channel-contracts.md +++ b/.claude/skills/publishing/ref/channel-contracts.md @@ -2,9 +2,7 @@ `release/publish-channel-contracts.toml` is the sole channel-contract source. It defines names, standard secret names, GitHub environments, public endpoints, -and liveness checks. **`release/org-destinations.toml`** defines mandatory -org-wide publish destinations and required channels; see -`ref/org-destinations.md`. This document defines only the operating procedure for +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 diff --git a/.claude/skills/publishing/ref/org-destinations.md b/.claude/skills/publishing/ref/org-destinations.md deleted file mode 100644 index 0c54555b..00000000 --- a/.claude/skills/publishing/ref/org-destinations.md +++ /dev/null @@ -1,32 +0,0 @@ -# Org publish destinations - -`release/org-destinations.toml` is vendored **byte-for-byte** with the publish kit. -It is the sole authority for: - -1. **Required channels** — every entry in `required_channels` must appear in - `release/install.json` → `channels`. Agents **must not** omit them when - bootstrapping or migrating a consumer repo. -2. **Fixed destinations** — `tap_repository`, `bucket_repository`, and the - winget `publisher_id` prefix are **not overridable** in `install.json`. - Wrong values fail `install.py`; omitted Homebrew/Scoop destination fields - are injected from this file at sync time. - -## Agent rules - -- Read `release/org-destinations.toml` before authoring or editing - `release/install.json`. -- Never propose a per-repo Homebrew tap, Scoop bucket, or non-`randlee.*` winget - identifier to “simplify” setup. -- Product-specific slots remain in `install.json`: formula path, Scoop manifest - path, winget package name suffix, crate list, binaries, bundled paths. -- PyPI and other channels not listed in `required_channels` stay opt-in per repo. - -## Verification - -```bash -python plugins/sc-publish/install.py --input release/install.json --dry-run . -``` - -Sync fails closed when mandatory channels or destinations drift. - -See also: `release/publish-channel-contracts.toml`, `ref/channel-contracts.md`. diff --git a/.github/scripts/tests/test_install.py b/.github/scripts/tests/test_install.py index 58668c43..a6312e60 100644 --- a/.github/scripts/tests/test_install.py +++ b/.github/scripts/tests/test_install.py @@ -106,7 +106,7 @@ def valid_values() -> dict[str, object]: "homebrew": { "workflow": "homebrew-publish.yml", "dispatch_inputs": {}, - "tap_repository": "randlee/homebrew-tap", + "tap_repository": "example/tap", "renderer_target": "x86_64-unknown-linux-gnu", "formulas": [ { @@ -129,13 +129,13 @@ def valid_values() -> dict[str, object]: "winget": { "workflow": "winget-publish.yml", "dispatch_inputs": {}, - "identifier": "randlee.example", + "identifier": "example.example", "installer_target": "x86_64-pc-windows-msvc", }, "scoop": { "workflow": "scoop-publish.yml", "dispatch_inputs": {}, - "bucket_repository": "randlee/scoop-bucket", + "bucket_repository": "example/scoop-bucket", "manifest_path": "bucket/example.json", "manifest_template": "release/scoop/manifest.json.j2", "installer_target": "x86_64-pc-windows-msvc", @@ -199,63 +199,22 @@ def test_load_install_values_rejects_missing_complete_contract_fields(self) -> N with self.assertRaisesRegex(Exception, "project.license"): INSTALL.load_install_values(path) - def test_load_install_values_rejects_omitted_mandatory_channels(self) -> None: - values = self.valid_values() - del values["channels"]["scoop"] - with tempfile.TemporaryDirectory() as directory: - path = Path(directory) / "input.json" - path.write_text(json.dumps(values), encoding="utf-8") - with self.assertRaisesRegex(Exception, "mandatory publish destinations"): - INSTALL.load_install_values(path) - - def test_load_install_values_rejects_non_org_homebrew_tap(self) -> None: - values = self.valid_values() - values["channels"]["homebrew"]["tap_repository"] = "other/homebrew-tap" - with tempfile.TemporaryDirectory() as directory: - path = Path(directory) / "input.json" - path.write_text(json.dumps(values), encoding="utf-8") - with self.assertRaisesRegex(Exception, "org-destinations.toml"): - INSTALL.load_install_values(path) - - def test_load_install_values_rejects_non_org_scoop_bucket(self) -> None: - values = self.valid_values() - values["channels"]["scoop"]["bucket_repository"] = "other/scoop-bucket" - with tempfile.TemporaryDirectory() as directory: - path = Path(directory) / "input.json" - path.write_text(json.dumps(values), encoding="utf-8") - with self.assertRaisesRegex(Exception, "org-destinations.toml"): - INSTALL.load_install_values(path) - - def test_load_install_values_rejects_non_org_winget_identifier(self) -> None: - values = self.valid_values() - values["channels"]["winget"]["identifier"] = "other.example" - with tempfile.TemporaryDirectory() as directory: - path = Path(directory) / "input.json" - path.write_text(json.dumps(values), encoding="utf-8") - with self.assertRaisesRegex(Exception, "publisher_id"): - INSTALL.load_install_values(path) - - def test_load_install_values_injects_org_destinations_when_omitted(self) -> None: - values = self.valid_values() - del values["channels"]["homebrew"]["tap_repository"] - del values["channels"]["scoop"]["bucket_repository"] - 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(loaded["channels"]["homebrew"]["tap_repository"], "randlee/homebrew-tap") - self.assertEqual(loaded["channels"]["scoop"]["bucket_repository"], "randlee/scoop-bucket") - - def test_load_install_values_accepts_pypi_only_when_not_in_required_channels(self) -> None: + 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") - with self.assertRaisesRegex(Exception, "mandatory publish destinations"): - INSTALL.load_install_values(path) + 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() @@ -266,15 +225,30 @@ def test_load_install_values_rejects_unknown_channel_names(self) -> None: with self.assertRaisesRegex(Exception, "unsupported name"): INSTALL.load_install_values(path) - def test_load_install_values_allows_omitted_optional_pypi(self) -> None: + 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() - del values["channels"]["pypi"] + 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) - self.assertNotIn("pypi", loaded["channels"]) - self.assertIn("homebrew", loaded["channels"]) + 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() diff --git a/README.sc-publish.md b/README.sc-publish.md index 6de2c09d..9ad62a06 100644 --- a/README.sc-publish.md +++ b/README.sc-publish.md @@ -30,12 +30,8 @@ python plugins/sc-publish/.github/scripts/bootstrap_sc_compose.py --venv The consumer input JSON is the single reviewable declaration of everything repository-specific: project identity, release targets, crates, release -binaries, Python distributions, and **product slots** within org-wide publish -destinations (formula path, Scoop manifest path, winget identifier suffix). -**Mandatory channels and shared tap/bucket/winget targets** come from the -vendored `release/org-destinations.toml`; `install.py` rejects omitted required -channels and non-org destination overrides. Only two files are rendered from the -JSON input — +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 diff --git a/docs/RELEASE_SECRETS.md b/docs/RELEASE_SECRETS.md index 9dc002c0..e8d06acd 100644 --- a/docs/RELEASE_SECRETS.md +++ b/docs/RELEASE_SECRETS.md @@ -43,14 +43,10 @@ Do **not** create per-repo Homebrew taps or Scoop buckets. When a repo declares in `release/install.json` must match the shared destinations above (wyvern: `randlee/homebrew-tap`, `randlee/scoop-bucket`). -**Enforcement:** vendored `release/org-destinations.toml` (from sc-publish kit) -lists `required_channels` and fixed destinations. `install.py` **fails closed** -if an agent omits a required channel or overrides a destination; omitted -Homebrew/Scoop destination fields are injected at sync time. Agents must not -opt out of this file when bootstrapping consumers. - -**sc-publish upstream:** same file ships in the kit; change org policy upstream -only, then re-sync — never hand-edit in consumer repos. +**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 diff --git a/release/org-destinations.toml b/release/org-destinations.toml deleted file mode 100644 index aa51c0ac..00000000 --- a/release/org-destinations.toml +++ /dev/null @@ -1,19 +0,0 @@ -# Org-wide publish destinations — vendored byte-for-byte into every kit consumer. -# install.py enforces this file: required channels must be declared in -# release/install.json, and destination fields must match (no overrides). -# Change upstream in sc-publish only; consumer hand-edits are defects. - -schema_version = 1 - -# Post-release channels every consumer MUST declare. Omitting any entry fails sync. -required_channels = ["homebrew", "scoop", "winget"] - -[destinations.homebrew] -tap_repository = "randlee/homebrew-tap" - -[destinations.scoop] -bucket_repository = "randlee/scoop-bucket" - -[destinations.winget] -publisher_id = "randlee" -pkgs_upstream = "microsoft/winget-pkgs" diff --git a/scripts/sync-sc-publish.sh b/scripts/sync-sc-publish.sh index 4dbb31fc..4870fe35 100755 --- a/scripts/sync-sc-publish.sh +++ b/scripts/sync-sc-publish.sh @@ -16,8 +16,8 @@ if [[ ! -d "${sc_publish_root}/.git" ]]; then exit 1 fi -SC_PUBLISH_REF="${SC_PUBLISH_REF:-0d37ed57c942f3f7e50c030566df25b7b009ed39}" -SC_PUBLISH_EXPECTED_SHA="${SC_PUBLISH_EXPECTED_SHA:-0d37ed57c942f3f7e50c030566df25b7b009ed39}" +SC_PUBLISH_REF="${SC_PUBLISH_REF:-43552e4c9e6d3435ed58a4a7eca42dd82f7edb74}" +SC_PUBLISH_EXPECTED_SHA="${SC_PUBLISH_EXPECTED_SHA:-43552e4c9e6d3435ed58a4a7eca42dd82f7edb74}" ( cd "${sc_publish_root}" From a042e3f6ffc8fedc62d1b73e34a2b18ee5388af9 Mon Sep 17 00:00:00 2001 From: Rand Lee Date: Thu, 27 Aug 2026 19:59:09 -0700 Subject: [PATCH 04/22] 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 --- .claude/agents/publisher-channel-protocol.md | 4 - .claude/agents/publisher.md | 14 - .claude/skills/publishing/preflight.xml.j2 | 1 - .../publishing/ref/release-state-strategy.md | 12 - .cursor/agents/publisher.md | 4 - .github/scripts/bootstrap_sc_compose.py | 128 +-- .github/scripts/release_artifacts.py | 24 +- .github/scripts/release_gate.sh | 8 +- .github/scripts/release_manifest.py | 20 - .../scripts/tests/test_publish_kit_scripts.py | 121 +-- .../scripts/tests/test_release_artifacts.py | 809 +----------------- .github/workflows/crates-publish.yml | 14 +- .github/workflows/homebrew-publish.yml | 23 +- .github/workflows/release-preflight.yml | 97 +-- .github/workflows/release.yml | 83 +- .github/workflows/scoop-publish.yml | 16 +- .gitignore | 1 + README.sc-publish.md | 7 +- .../.plan-hardening/j3-release-gate.md | 40 + .../sc-publish-extension-requests.md | 39 + .../.plan-hardening/upstream-tracking.md | 53 +- .../phase-J/publish-architecture-decision.md | 14 +- release/sc-publish-pin.toml | 5 + scripts/sync-sc-publish.sh | 62 +- 24 files changed, 348 insertions(+), 1251 deletions(-) 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/sc-publish-pin.toml diff --git a/.claude/agents/publisher-channel-protocol.md b/.claude/agents/publisher-channel-protocol.md index 5464a513..f4c4b6b0 100644 --- a/.claude/agents/publisher-channel-protocol.md +++ b/.claude/agents/publisher-channel-protocol.md @@ -12,10 +12,6 @@ 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 diff --git a/.claude/agents/publisher.md b/.claude/agents/publisher.md index 3d26a87b..26c69fe8 100644 --- a/.claude/agents/publisher.md +++ b/.claude/agents/publisher.md @@ -35,9 +35,6 @@ 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. -If a release task requires a direct template render, also read -`.claude/skills/publishing/ref/renderer-contract.md` before doing so. - ## Output Format Send the assignment's named recipient one concise ATM completion message @@ -185,10 +182,6 @@ exist; run `Release Preflight` and report its sanitized result. 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 @@ -200,13 +193,6 @@ exist; run `Release Preflight` and report its sanitized result. 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. diff --git a/.claude/skills/publishing/preflight.xml.j2 b/.claude/skills/publishing/preflight.xml.j2 index 58880622..279645eb 100644 --- a/.claude/skills/publishing/preflight.xml.j2 +++ b/.claude/skills/publishing/preflight.xml.j2 @@ -27,7 +27,6 @@ required_variables: {{ starting_state }} {{ preflight_stage }} {{ manifest_path }} - {{ already_published_channels | default('') }} .claude/skills/publishing/ref/release-state-strategy.md diff --git a/.claude/skills/publishing/ref/release-state-strategy.md b/.claude/skills/publishing/ref/release-state-strategy.md index 37eb82d1..a8917369 100644 --- a/.claude/skills/publishing/ref/release-state-strategy.md +++ b/.claude/skills/publishing/ref/release-state-strategy.md @@ -13,11 +13,6 @@ the source of truth for artifacts, channels, and publish order. 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 @@ -69,10 +64,3 @@ 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/.cursor/agents/publisher.md b/.cursor/agents/publisher.md index 60ddd230..2ce8c055 100644 --- a/.cursor/agents/publisher.md +++ b/.cursor/agents/publisher.md @@ -33,10 +33,6 @@ Repository-specific data comes only from: - `.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`. diff --git a/.github/scripts/bootstrap_sc_compose.py b/.github/scripts/bootstrap_sc_compose.py index 487a6457..fa76a8eb 100644 --- a/.github/scripts/bootstrap_sc_compose.py +++ b/.github/scripts/bootstrap_sc_compose.py @@ -4,15 +4,16 @@ from __future__ import annotations import argparse +import re 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" +# This is an intentional historical compatibility floor, not a consumer's +# current workspace version. 1.4.1 is the first published wheel with the +# renderer features required by publish-kit templates. +SC_COMPOSE_VERSION = "1.4.1" def python_path(venv: Path) -> Path: @@ -37,110 +38,55 @@ def installed_version(python: Path) -> str | None: 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: +def version_components(value: str) -> tuple[int, ...]: + """Return numeric release components for a stable wheel version.""" + if not re.fullmatch(r"\d+(?:\.\d+)*", value): raise SystemExit( - "managed environment has incompatible sc-compose wheel: " - f"found {installed!r}; required exactly {SC_COMPOSE_VERSION}." + "cannot verify managed sc-compose wheel version " + f"{value!r}; required >= {SC_COMPOSE_VERSION}" ) + return tuple(int(component) for component in value.split(".")) -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: +def require_version_floor(installed: str) -> None: + """Fail before downstream pytest can import a stale renderer binding.""" + if version_components(installed) < version_components(SC_COMPOSE_VERSION): raise SystemExit( - "managed environment has incompatible sc-compose wheel: " - "installation completed but its version could not be determined" + "managed environment has incompatible sc-compose wheel: stale version " + f"{installed!r}; required >= {SC_COMPOSE_VERSION}. Use a new --venv path." ) - 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) + existing = installed_version(python) + if existing is None: + subprocess.run( + [ + str(python), + "-m", + "pip", + "install", + "--disable-pip-version-check", + f"sc-compose=={SC_COMPOSE_VERSION}", + ], + check=True, + stdout=sys.stderr, + ) + 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_version_floor(existing) print(python) return 0 diff --git a/.github/scripts/release_artifacts.py b/.github/scripts/release_artifacts.py index 6af552c3..aa9c857f 100644 --- a/.github/scripts/release_artifacts.py +++ b/.github/scripts/release_artifacts.py @@ -34,12 +34,12 @@ manifest_python_upload_tool, manifest_rust_toolchain, manifest_workspace_toml, + registry_version_state, 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]]: @@ -850,6 +850,22 @@ def cmd_cargo_build_bin_args(args: argparse.Namespace) -> int: return 0 +def cmd_check_version_unpublished(args: argparse.Namespace) -> int: + """Detect already-published crates via the contract's exact version_lookup_url.""" + manifest = load_manifest(Path(args.manifest), with_channel_contracts=True) + published = [] + for crate in manifest["crates"]: + check = _public_registry_checks( + manifest["channel_contracts"], "crates_io", crate["package"], args.version + )[0] + if registry_version_state(check["version_lookup_url"]) == "published": + 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 main() -> int: parser = argparse.ArgumentParser() sub = parser.add_subparsers(dest="cmd", required=True) @@ -926,11 +942,6 @@ def main() -> int: 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) @@ -978,7 +989,6 @@ def main() -> int: 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() diff --git a/.github/scripts/release_gate.sh b/.github/scripts/release_gate.sh index de3ad33d..dc42f554 100755 --- a/.github/scripts/release_gate.sh +++ b/.github/scripts/release_gate.sh @@ -7,7 +7,6 @@ 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 @@ -51,15 +50,10 @@ fi python3 .github/scripts/release_artifacts.py check-version-unpublished \ --manifest "$MANIFEST" \ - --version "$VERSION" \ - --already-published-channels "$ALREADY_PUBLISHED_CHANNELS" >/dev/null + --version "$VERSION" >/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 index 6c0e849c..7b0cd3f5 100644 --- a/.github/scripts/release_manifest.py +++ b/.github/scripts/release_manifest.py @@ -516,26 +516,6 @@ def registry_version_state(url: str, timeout: int = 20) -> str: 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] diff --git a/.github/scripts/tests/test_publish_kit_scripts.py b/.github/scripts/tests/test_publish_kit_scripts.py index 6fe8dca8..705943cd 100644 --- a/.github/scripts/tests/test_publish_kit_scripts.py +++ b/.github/scripts/tests/test_publish_kit_scripts.py @@ -3,14 +3,11 @@ 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()) @@ -159,14 +156,6 @@ def test_release_gate_accepts_main_with_post_cut_develop_drift(self) -> None: 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( [ @@ -180,16 +169,13 @@ def test_release_gate_accepts_main_with_post_cut_develop_drift(self) -> None: "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: @@ -264,114 +250,31 @@ def test_release_artifacts_cli_exposes_read_only_inquiry(self) -> None: 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: + def test_bootstrap_enforces_the_documented_renderer_version_floor(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") + probe = text[text.index("def installed_version"):text.index("def version_components")] + self.assertIn('SC_COMPOSE_VERSION = "1.4.1"', text) + self.assertIn("historical compatibility floor", text) 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.assertNotIn("existing != SC_COMPOSE_VERSION", text) + self.assertIn("require_version_floor(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") + def test_bootstrap_rejects_a_too_old_wheel(self) -> None: with self.assertRaisesRegex( SystemExit, - r"found '1\.5\.1'; required exactly 1\.5\.0", + r"stale version '1\.4\.0'; required >= 1\.4\.1", ): - BOOTSTRAP.require_pinned_version("1.5.1") - - def test_bootstrap_accepts_only_the_pinned_wheel(self) -> None: - BOOTSTRAP.require_pinned_version("1.5.0") + BOOTSTRAP.require_version_floor("1.4.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) + def test_bootstrap_accepts_the_floor_and_newer_wheels(self) -> None: + BOOTSTRAP.require_version_floor("1.4.1") + BOOTSTRAP.require_version_floor("1.5.0") if __name__ == "__main__": diff --git a/.github/scripts/tests/test_release_artifacts.py b/.github/scripts/tests/test_release_artifacts.py index 7bf116c4..962afec1 100644 --- a/.github/scripts/tests/test_release_artifacts.py +++ b/.github/scripts/tests/test_release_artifacts.py @@ -2,16 +2,13 @@ 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 @@ -238,65 +235,12 @@ def test_release_artifact_cli_stays_below_the_script_line_ceiling() -> None: ).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") @@ -321,366 +265,6 @@ 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" @@ -870,30 +454,6 @@ def test_crates_leg_is_separate_and_independently_retryable() -> None: 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() @@ -936,8 +496,6 @@ def test_no_single_repo_concerns_leak_into_kit_workflows_actions_or_scripts() -> "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", @@ -951,10 +509,8 @@ def test_no_single_repo_concerns_leak_into_kit_workflows_actions_or_scripts() -> ) kit_actions = ( "extract-published-renderer", - "install-linux-native-deps", "setup-lint-toolchain", "setup-python-release-build", - "setup-renderer", "setup-sc-lint", "verify-published-release", ) @@ -962,7 +518,6 @@ def test_no_single_repo_concerns_leak_into_kit_workflows_actions_or_scripts() -> "bootstrap_sc_compose.py", "release_artifacts.py", "release_manifest.py", - "release_registry.py", "release_gate.sh", ) github_root = repo_root() / ".github" @@ -1119,7 +674,6 @@ 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): @@ -1128,13 +682,9 @@ def test_crates_already_published_detection_uses_exact_version_lookup() -> None: 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 "registry_version_state" in script_text assert "must_be_absent" not in release_text # policy lives in the contract assert "registry lookup failed" in manifest_module_text @@ -1235,15 +785,6 @@ def channel_config(tag: str) -> dict: 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( @@ -1581,6 +1122,7 @@ def test_manifest_drives_non_disclosing_preflight_secret_plan() -> None: {"environment": "testpypi", "name": "TEST_PYPI_API_TOKEN"}, ] assert plan["liveness_channel_checks"] == [ + {"channel": "crates_io", "name": "CARGO_REGISTRY_TOKEN", "kind": "crates_io"}, {"channel": "homebrew", "name": "HOMEBREW_TAP_TOKEN", "kind": "github"}, {"channel": "winget", "name": "WINGET_GITHUB_TOKEN", "kind": "github"}, {"channel": "scoop", "name": "SCOOP_BUCKET_TOKEN", "kind": "github"}, @@ -1598,7 +1140,6 @@ def test_manifest_drives_non_disclosing_preflight_secret_plan() -> None: "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"] == { @@ -2015,27 +1556,6 @@ def test_public_registry_inquiry_plan_is_contract_derived_and_read_only() -> Non 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() @@ -2102,294 +1622,16 @@ def test_release_preflight_requires_each_standardized_secret() -> None: 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 "Verify protected Python environment secret metadata" 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://crates.io/api/v1/me" in text assert "https://api.github.com/user" in text - assert "rotate or replace it" not in text + assert "rotate or replace it" 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: @@ -2423,8 +1665,7 @@ def test_channel_recovery_workflows_require_a_published_release() -> None: 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 "uses: ./.github/actions/extract-published-renderer" in scoop_text assert "Render manifest-selected formulas with the published renderer" in homebrew_text assert '--tag "${{ inputs.tag }}"' in homebrew_text @@ -2434,8 +1675,7 @@ def test_channel_recovery_workflows_require_a_published_release() -> None: 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 "uses: ./.github/actions/extract-published-renderer" in homebrew_text assert "install_block" not in homebrew_text assert "bundled_paths" in homebrew_text @@ -2443,26 +1683,13 @@ def test_channel_recovery_workflows_require_a_published_release() -> None: repo_root() / ".github" / "actions" - / "setup-renderer" + / "extract-published-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 "binary-path" in renderer_action + assert "Published renderer archive is missing ${RENDERER_BINARY_PATH}" 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] @@ -2605,13 +1832,12 @@ def test_publish_kit_guidance_is_manifest_driven_and_token_non_disclosing() -> N repo_root() / ".claude" / "skills" / "publishing" / "evals" / "channel-name-inquiry.md" ).read_text(encoding="utf-8") - for text in (guide_text, checklist_text): + for text in (publisher_text, 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" @@ -2784,7 +2010,6 @@ def test_publishing_task_templates_render_recipient_contract(tmp_path: Path) -> "worktree_path": "/tmp/eval", "branch": "develop", "manifest_path": "release/publish-artifacts.toml", - "already_published_channels": "crates_io", }, ), ( @@ -2809,8 +2034,6 @@ def test_publishing_task_templates_render_recipient_contract(tmp_path: Path) -> 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: @@ -2828,10 +2051,6 @@ def test_release_preflight_collects_independent_failures_before_denial() -> None 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 @@ -2850,7 +2069,7 @@ def test_release_workflow_rehearsal_mode_avoids_production_side_effects() -> Non 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 "echo \"release_ref=$main_sha\" >> \"$GITHUB_OUTPUT\"" in text assert "needs.gate-and-tag.outputs.release_target == 'production'" in text @@ -2867,7 +2086,7 @@ def test_release_workflow_checks_out_repo_before_local_python_setup_action() -> steps: - uses: actions/checkout@v4 with: - ref: ${{ needs.gate-and-tag.outputs.build_ref }} + ref: ${{ needs.gate-and-tag.outputs.release_ref }} - uses: ./.github/actions/setup-python-release-build""" sdist_job = """ build-python-sdists: if: ${{ needs.release-plan.outputs.has_python_sdists == 'true' }} @@ -2879,7 +2098,7 @@ def test_release_workflow_checks_out_repo_before_local_python_setup_action() -> steps: - uses: actions/checkout@v4 with: - ref: ${{ needs.gate-and-tag.outputs.build_ref }} + ref: ${{ needs.gate-and-tag.outputs.release_ref }} - uses: ./.github/actions/setup-python-release-build""" assert wheels_job in text diff --git a/.github/workflows/crates-publish.yml b/.github/workflows/crates-publish.yml index 86d7624e..f711da37 100644 --- a/.github/workflows/crates-publish.yml +++ b/.github/workflows/crates-publish.yml @@ -57,9 +57,6 @@ jobs: 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: @@ -84,12 +81,13 @@ jobs: 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 ;; + status="$(curl --location --max-time 20 --silent --show-error --output /dev/null \ + --write-out '%{http_code}' --user-agent 'sc-publish-kit' "${url}" || true)" + case "${status}" in + 200) return 0 ;; + 404) return 1 ;; *) - echo "crates.io state for ${crate}@${version} is indeterminate (state ${state})" >&2 + echo "crates.io state for ${crate}@${version} is indeterminate (status ${status})" >&2 exit 1 ;; esac diff --git a/.github/workflows/homebrew-publish.yml b/.github/workflows/homebrew-publish.yml index 1d03e629..237963f3 100644 --- a/.github/workflows/homebrew-publish.yml +++ b/.github/workflows/homebrew-publish.yml @@ -67,8 +67,6 @@ jobs: 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: @@ -98,7 +96,9 @@ jobs: config = json.loads(os.environ["CHANNEL_CONFIG"]) project = config["project"] + channel = config["channel"] assets = {} + archives_by_target = {} def download(target): filename = f"{project['archive_prefix']}_{os.environ['RELEASE_VERSION']}_{target['target']}.{target['archive']}" @@ -108,6 +108,7 @@ jobs: ["curl", "-fsSL", "--retry", "5", "--retry-delay", "30", "--retry-all-errors", "-o", str(path), url], check=True, ) + archives_by_target[target["target"]] = path return url, path for asset in config["channel"]["assets"]: @@ -124,8 +125,24 @@ jobs: raise SystemExit( "Homebrew assets must contain exactly: macos_arm, macos_intel, linux" ) + renderer_target = config["release_targets"][channel["renderer_target"]] + renderer_archive = archives_by_target.get(renderer_target["target"]) + if renderer_archive is None: + _url, renderer_archive = download(renderer_target) Path("homebrew-release-assets.json").write_text(json.dumps(assets), encoding="utf-8") + with Path(os.environ["GITHUB_ENV"]).open("a", encoding="utf-8") as env_file: + env_file.write(f"PUBLISHED_RENDERER_ARCHIVE={renderer_archive}\n") + env_file.write( + "PUBLISHED_RENDERER_EXTRACT_DIR=" + f"{project['archive_prefix']}_{os.environ['RELEASE_VERSION']}_{renderer_target['target']}\n" + ) PY + - name: Extract manifest-declared published renderer + uses: ./.github/actions/extract-published-renderer + with: + archive: ${{ env.PUBLISHED_RENDERER_ARCHIVE }} + extract-dir: ${{ env.PUBLISHED_RENDERER_EXTRACT_DIR }} + binary-path: ${{ fromJSON(needs.verify-release.outputs.channel_config).project.renderer_archive_path }} - name: Render manifest-selected formulas with the published renderer shell: bash env: @@ -142,7 +159,7 @@ jobs: config = json.loads(os.environ["CHANNEL_CONFIG"]) project = config["project"] channel = config["channel"] - assets = json.loads(Path("homebrew-release-assets.json").read_text()) + assets = {asset["key"]: asset for asset in json.loads(Path("homebrew-release-assets.json").read_text())} renderer = os.environ["PUBLISHED_RENDERER"] for index, formula in enumerate(channel["formulas"]): binaries = formula["binaries"] diff --git a/.github/workflows/release-preflight.yml b/.github/workflows/release-preflight.yml index f2d780bb..c7d685be 100644 --- a/.github/workflows/release-preflight.yml +++ b/.github/workflows/release-preflight.yml @@ -12,16 +12,10 @@ 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 -# `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 +permissions: + actions: read + contents: read jobs: preflight: @@ -103,7 +97,7 @@ jobs: echo "All manifest-required repository secrets are available." - id: environment_secrets - name: Inspect protected Python environment secret metadata (informational) + name: Verify protected Python environment secret metadata if: ${{ always() && steps.secret_plan.outcome == 'success' }} continue-on-error: true shell: bash @@ -114,10 +108,9 @@ jobs: 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}") + failures+=("Could not inspect secret metadata for protected environment ${environment_name}.") elif ! grep -Fxq -- "${secret_name}" <<<"${names}"; then failures+=("GitHub Actions environment ${environment_name} is missing required secret ${secret_name}.") fi @@ -126,11 +119,7 @@ jobs: 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 + echo "All manifest-required protected-environment secret names are present." - id: github_environments name: Verify contract-declared GitHub environments exist @@ -163,6 +152,7 @@ jobs: 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 }} @@ -174,6 +164,9 @@ jobs: token="${!secret_name:-}" channel_status=success case "${check_kind}" in + crates_io) + command=(curl --fail --silent --show-error --header "Authorization: ${token}" https://crates.io/api/v1/me) + ;; github) command=(curl --fail --silent --show-error --header "Authorization: Bearer ${token}" --header "Accept: application/vnd.github+json" https://api.github.com/user) ;; @@ -184,7 +177,7 @@ jobs: ;; esac if ! "${command[@]}" >/dev/null; then - failures+=("GitHub Actions secret ${secret_name} is present but rejected by its publish service.") + failures+=("GitHub Actions secret ${secret_name} is present but rejected by its publish service; rotate or replace it.") channel_status=failure fi channel_outcomes="$(jq -c --arg channel "${channel}" --arg status "${channel_status}" '.[$channel]=$status' <<<"${channel_outcomes}")" @@ -212,10 +205,6 @@ jobs: 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 @@ -294,71 +283,59 @@ jobs: 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}" + '${{ steps.build_plan.outputs.workspace_toml }}' - 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}" + --version '${{ steps.meta.outputs.release_version }}' - 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 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 + status_code() { + local status + status="$(curl --location --max-time 20 --silent --show-error --output /dev/null \ + --write-out '%{http_code}' "$1" || true)" + case "${status}" in + 200|404) printf '%s' "${status}" ;; + *) printf '000' ;; + esac } + 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:*) + project_status="$(status_code "${project_url}")" + version_status="$(status_code "${version_url}")" + case "${project_status}:${version_status}:${version_policy}" in + 404:404:*) printf '%s: %s name %s is apparently available (not a reservation).\n' \ "${agent}" "${registry}" "${normalized_name}" ;; - published:absent:*) + 200:404:*) 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 + 200:200:must_be_absent) + failures+=("${channel}/${registry}: ${normalized_name}@${expected_version} is already published.") ;; - published:published:informational) + 200:200: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}).") + failures+=("${channel}/${registry}: registry state for ${normalized_name}@${expected_version} is indeterminate (project=${project_status}, version=${version_status}).") ;; esac done < <(python3 .github/scripts/release_artifacts.py public-registry-check-plan \ @@ -414,22 +391,14 @@ jobs: 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}" \ + --argjson repository_secret_channels "${REPOSITORY_SECRET_CHANNELS:-{}}" \ --arg environment_secrets "${ENVIRONMENT_SECRETS}" \ --arg credential_liveness "${CREDENTIAL_LIVENESS}" \ - --argjson credential_liveness_channels "${credential_liveness_channels_json}" \ + --argjson credential_liveness_channels "${CREDENTIAL_LIVENESS_CHANNELS:-{}}" \ --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}')" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 826086a5..c66b1037 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -20,11 +20,6 @@ on: 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 @@ -39,7 +34,7 @@ jobs: 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 }} + release_ref: ${{ steps.release-ref.outputs.release_ref }} steps: - uses: actions/checkout@v4 with: @@ -96,10 +91,7 @@ jobs: --workspace-toml '${{ steps.build_plan.outputs.workspace_toml }}' - 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}" + 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 }}' - name: Ensure tag is correct or create it id: release-ref @@ -109,29 +101,25 @@ jobs: 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}" + main_sha="$(git rev-parse origin/main)" 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 + remote_tag_sha="$(git ls-remote --tags origin "refs/tags/${tag}" | awk '{print $1}' || true)" + if [[ -n "$remote_tag_sha" ]]; then + if [[ "$remote_tag_sha" == "$main_sha" ]]; then + echo "Tag ${tag} already exists and points to origin/main (${main_sha}), skipping creation" + echo "release_ref=$tag" >> "$GITHUB_OUTPUT" + exit 0 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" + echo "Tag ${tag} exists but points to ${remote_tag_sha}, not origin/main (${main_sha})" >&2 + exit 1 fi - else - echo "Rehearsal mode: validating release tag ${tag} locally only; not pushing any tag to origin" + git tag "$tag" origin/main + git push origin "$tag" + echo "release_ref=$tag" >> "$GITHUB_OUTPUT" + exit 0 fi - echo "build_ref=$main_sha" >> "$GITHUB_OUTPUT" + echo "Rehearsal mode: validating release tag ${tag} locally only; not pushing any tag to origin" + echo "release_ref=$main_sha" >> "$GITHUB_OUTPUT" build: needs: [gate-and-tag, release-plan] @@ -142,7 +130,7 @@ jobs: steps: - uses: actions/checkout@v4 with: - ref: ${{ needs.gate-and-tag.outputs.build_ref }} + ref: ${{ needs.gate-and-tag.outputs.release_ref }} - name: Install Rust toolchain uses: dtolnay/rust-toolchain@master @@ -153,9 +141,6 @@ jobs: - name: Ensure cross-compilation target run: rustup target add ${{ matrix.target }} - - name: Install Linux native build deps - uses: ./.github/actions/install-linux-native-deps - - name: Cache cargo uses: actions/cache@v4 with: @@ -208,7 +193,7 @@ jobs: shutil.rmtree(stage) (stage / "bin").mkdir(parents=True) for binary in config["binaries"]: - filename = binary["name"] + (".exe" if "windows" in target_name else "") + filename = binary["name"] + (".exe" if target_name.endswith("windows-msvc") 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"]) @@ -244,16 +229,13 @@ jobs: steps: - uses: actions/checkout@v4 with: - ref: ${{ needs.gate-and-tag.outputs.build_ref }} + ref: ${{ needs.gate-and-tag.outputs.release_ref }} - name: Install Rust toolchain uses: dtolnay/rust-toolchain@master with: toolchain: ${{ needs.release-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: @@ -277,12 +259,13 @@ jobs: 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 ;; + status="$(curl --location --max-time 20 --silent --show-error --output /dev/null \ + --write-out '%{http_code}' --user-agent 'sc-publish-kit' "${url}" || true)" + case "${status}" in + 200) return 0 ;; + 404) return 1 ;; *) - echo "crates.io state for ${crate}@${version} is indeterminate (state ${state})" >&2 + echo "crates.io state for ${crate}@${version} is indeterminate (status ${status})" >&2 exit 1 ;; esac @@ -321,7 +304,7 @@ jobs: steps: - uses: actions/checkout@v4 with: - ref: ${{ needs.gate-and-tag.outputs.build_ref }} + ref: ${{ needs.gate-and-tag.outputs.release_ref }} - id: manifest name: Read Python distribution matrices from release manifest shell: bash @@ -350,10 +333,10 @@ jobs: steps: - uses: actions/checkout@v4 with: - ref: ${{ needs.gate-and-tag.outputs.build_ref }} + ref: ${{ needs.gate-and-tag.outputs.release_ref }} - uses: ./.github/actions/setup-python-release-build with: - release_ref: ${{ needs.gate-and-tag.outputs.build_ref }} + release_ref: ${{ needs.gate-and-tag.outputs.release_ref }} release_version: ${{ needs.gate-and-tag.outputs.release_version }} pyproject: ${{ matrix.pyproject }} build_system: ${{ matrix.build_system }} @@ -382,10 +365,10 @@ jobs: steps: - uses: actions/checkout@v4 with: - ref: ${{ needs.gate-and-tag.outputs.build_ref }} + ref: ${{ needs.gate-and-tag.outputs.release_ref }} - uses: ./.github/actions/setup-python-release-build with: - release_ref: ${{ needs.gate-and-tag.outputs.build_ref }} + release_ref: ${{ needs.gate-and-tag.outputs.release_ref }} release_version: ${{ needs.gate-and-tag.outputs.release_version }} pyproject: ${{ matrix.pyproject }} build_system: ${{ matrix.build_system }} @@ -419,7 +402,7 @@ jobs: steps: - uses: actions/checkout@v4 with: - ref: ${{ needs.gate-and-tag.outputs.build_ref }} + ref: ${{ needs.gate-and-tag.outputs.release_ref }} - uses: actions/download-artifact@v4 with: pattern: 'python-wheel-*' @@ -475,7 +458,7 @@ jobs: steps: - uses: actions/checkout@v4 with: - ref: ${{ needs.gate-and-tag.outputs.build_ref }} + ref: ${{ needs.gate-and-tag.outputs.release_tag }} - id: expected_assets name: Read expected release asset patterns from manifest diff --git a/.github/workflows/scoop-publish.yml b/.github/workflows/scoop-publish.yml index d10ea7b8..cf0fdaf0 100644 --- a/.github/workflows/scoop-publish.yml +++ b/.github/workflows/scoop-publish.yml @@ -68,8 +68,6 @@ jobs: 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: @@ -112,6 +110,8 @@ jobs: target = config["release_targets"][channel["installer_target"]] url, archive = download(target) + renderer_target = config["release_targets"][channel["renderer_target"]] + _renderer_url, renderer_archive = download(renderer_target) variables = { "version": os.environ["RELEASE_VERSION"], "description": project["description"], @@ -123,7 +123,19 @@ jobs: "binary": channel["binary"], } Path("scoop-vars.json").write_text(json.dumps(variables), encoding="utf-8") + with Path(os.environ["GITHUB_ENV"]).open("a", encoding="utf-8") as env_file: + env_file.write(f"PUBLISHED_RENDERER_ARCHIVE={renderer_archive}\n") + env_file.write( + "PUBLISHED_RENDERER_EXTRACT_DIR=" + f"{project['archive_prefix']}_{os.environ['RELEASE_VERSION']}_{renderer_target['target']}\n" + ) PY + - name: Extract manifest-declared published renderer + uses: ./.github/actions/extract-published-renderer + with: + archive: ${{ env.PUBLISHED_RENDERER_ARCHIVE }} + extract-dir: ${{ env.PUBLISHED_RENDERER_EXTRACT_DIR }} + binary-path: ${{ fromJSON(needs.verify-release.outputs.channel_config).project.renderer_archive_path }} - name: Render Scoop manifest with published renderer shell: bash env: diff --git a/.gitignore b/.gitignore index e77594ed..8153d1e4 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ Cargo.lock .sc/ .sc-compose/ .sc-publish-venv/ +.sc-publish-kit/ tests/e2e/node_modules/ tests/e2e/test-results/ tests/e2e/playwright-report/ diff --git a/README.sc-publish.md b/README.sc-publish.md index 9ad62a06..84bcc5ff 100644 --- a/README.sc-publish.md +++ b/README.sc-publish.md @@ -17,7 +17,7 @@ customization mechanism. 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. +# 1. Provision the pinned sc-compose renderer bindings 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 @@ -73,11 +73,6 @@ Each publish channel — `github_release`, `crates_io`, `pypi`, `homebrew`, - 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 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..4a5cebf0 --- /dev/null +++ b/docs/plans/phase-J/.plan-hardening/j3-release-gate.md @@ -0,0 +1,40 @@ +# 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 | **pending** | After atm-core publish succeeds from `develop` | +| atm-core AT-style qualification + publish from `develop` | atm-core | **in progress** | v1.4.4 RC retry | +| Org pin published | atm/sc-publish | **pending** | Blessed SHA TBD after qualification | +| wyvern `release/sc-publish-pin.toml` bumped + sync dry-run 0 | wyvern | **pending** | After org pin | + +## 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 | **open** | `manifests/r/randlee/wyvern` 404 | +| PR #145 consumer pin merged to `integrate/phase-J` | **pending** | [PR #145](https://github.com/randlee/wyvern/pull/145) | + +## 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 6847c877..c0c2ee4f 100644 --- a/docs/plans/phase-J/.plan-hardening/upstream-tracking.md +++ b/docs/plans/phase-J/.plan-hardening/upstream-tracking.md @@ -4,49 +4,38 @@ Each blocker is exactly one state: | Blocker | State | PR / commit | Signed waiver | Effect | |---------|-------|-------------|---------------|--------| -| CR-001 Linux webview deps | **resolved** | sc-publish [PR #61](https://github.com/randlee/sc-publish/pull/61) merged → `main` @ `43552e4c9e6d3435ed58a4a7eca42dd82f7edb74` | | Kit jobs install webkit/wayland apt packages via `.github/actions/install-linux-native-deps`. | -| CR-002 Homebrew/Scoop renderer | **resolved** | same merge as CR-001 | | `homebrew-publish.yml` and `scoop-publish.yml` bootstrap the pinned renderer via `.github/actions/setup-renderer` (not the product binary). `release/install.json` keeps non-empty `renderer_archive_path` = `bin/wyvern`. | +| CR-001 Linux webview deps | **pending** | sc-publish [PR #63](https://github.com/randlee/sc-publish/pull/63) (includes #61) | | Awaiting org-wide qualification before wyvern re-pin. Wyvern stays on `42e0fce` until blessed SHA published. | +| CR-002 Homebrew/Scoop renderer | **pending** | same as CR-001 | | Same gate. #61 `setup-renderer` path must pass atm-core AT-style qualification. | +| CR-RC Git identity on RC tag | **pending** | PR #63 (supersedes #62) | | atm-core v1.4.4 RC failed without this; included in PR #63. | **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`. -- This closeout does **not** sign waivers. +- Wyvern does **not** modify sc-publish for repo-specific quirks; consumer changes only. -## Disposition notes +## Wyvern kit pin (consumer) -### CR-001 +| Item | Value | +|------|-------| +| Pin file | `release/sc-publish-pin.toml` | +| Current revision | `42e0fcea23f730fae0ef3d08b060cd4df6a2602e` (atm-core AT.2) | +| Sync entrypoint | `scripts/sync-sc-publish.sh` → isolated `.sc-publish-kit/` cache | +| Target org revision | `develop` @ `5d7c749` (PR #63 merge) after qualification + org pin | -Kit composite action `.github/actions/install-linux-native-deps` installs -`libwebkit2gtk-4.1-dev`, `libwayland-dev`, and the matching runtime/Xvfb -packages. Wired into: - -- `release.yml` build matrix (Linux no-op on macOS/Windows) -- `release.yml` crates publish job -- `release-preflight.yml` (when the manifest has crates) -- `crates-publish.yml` - -### CR-002 - -Both channel workflows call `.github/actions/setup-renderer`, which runs -`bootstrap_sc_compose.py --write-cli` and exports `PUBLISHED_RENDERER`. They no -longer extract `project.renderer_archive_path` from the Linux product archive. -`renderer_archive_path` remains required by the kit schema while Scoop/Homebrew -are declared; Wyvern keeps `bin/wyvern` (archive binary path, not the renderer). - -Wyvern is re-synced to sc-publish `43552e4` via `scripts/sync-sc-publish.sh`. +**Note:** sc-publish `main` @ `43552e4` (#61 merged unqualified) is **not** wyvern's pin. Do not re-sync to `main` until org blessed release. ## j.2 closeout extras | Item | Status | |------|--------| -| `WINGET_GITHUB_TOKEN` | Present in `gh secret list` (shared org PAT — not created in j.2) | -| `SCOOP_BUCKET_TOKEN` | Present in `gh secret list` (shared org PAT — not created in j.2) | -| `randlee/scoop-bucket` | Public, cloneable: https://github.com/randlee/scoop-bucket ; `bucket/` has only `.gitkeep` (workflow seeds `bucket/wyvern.json`) | -| Scoop push probe | Authenticated `gh api repos/randlee/scoop-bucket` as `randlee` reports `permissions.push=true` | -| `randlee.wyvern` in `winget-pkgs` | **Absent** (`manifests/r/randlee/wyvern` 404). Owner bootstrap **before j.3**. | +| `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 (already absent on this branch) | +| `scripts/validate_release.py` | Deleted | +| Extension request doc | [sc-publish-extension-requests.md](sc-publish-extension-requests.md) | -Updated in j.2 sprint closeout. +Updated after org-wide kit policy correction (2026-08-28). diff --git a/docs/plans/phase-J/publish-architecture-decision.md b/docs/plans/phase-J/publish-architecture-decision.md index 84e1001a..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** @@ -48,8 +50,10 @@ other Rust repos. - 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/sc-publish-pin.toml b/release/sc-publish-pin.toml new file mode 100644 index 00000000..324df007 --- /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. +# atm-core AT.2 receipt: 42e0fce (2026-08-27). +repository = "https://github.com/randlee/sc-publish.git" +revision = "42e0fcea23f730fae0ef3d08b060cd4df6a2602e" diff --git a/scripts/sync-sc-publish.sh b/scripts/sync-sc-publish.sh index 4870fe35..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:-43552e4c9e6d3435ed58a4a7eca42dd82f7edb74}" -SC_PUBLISH_EXPECTED_SHA="${SC_PUBLISH_EXPECTED_SHA:-43552e4c9e6d3435ed58a4a7eca42dd82f7edb74}" +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 @@ -44,4 +72,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) (pin ${SC_PUBLISH_REF})" +echo "sc-publish kit synced from ${kit_cache} @ $(git -C "${kit_cache}" rev-parse --short HEAD) (pin ${SC_PUBLISH_REF})" From 8490a190c0e4b6463099ce7a7fb19dc7e061f2e0 Mon Sep 17 00:00:00 2001 From: Rand Lee Date: Thu, 27 Aug 2026 19:59:24 -0700 Subject: [PATCH 05/22] docs(phase-J): mark PR #145 merged; isolated sync validated @ 42e0fce Co-authored-by: Cursor --- docs/plans/phase-J/.plan-hardening/j3-release-gate.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/plans/phase-J/.plan-hardening/j3-release-gate.md b/docs/plans/phase-J/.plan-hardening/j3-release-gate.md index 4a5cebf0..d9c904de 100644 --- a/docs/plans/phase-J/.plan-hardening/j3-release-gate.md +++ b/docs/plans/phase-J/.plan-hardening/j3-release-gate.md @@ -23,7 +23,7 @@ j.3 cannot start until every gate below is **green**. Do not merge | Secrets present (`WINGET_*`, `SCOOP_*`, …) | **done** | `gh secret list` | | `randlee/scoop-bucket` cloneable | **done** | Public repo | | `randlee.wyvern` winget bootstrap | **open** | `manifests/r/randlee/wyvern` 404 | -| PR #145 consumer pin merged to `integrate/phase-J` | **pending** | [PR #145](https://github.com/randlee/wyvern/pull/145) | +| 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) From b3e81fcad086c5e022b90654a81ce2047d90dcda Mon Sep 17 00:00:00 2001 From: Rand Lee Date: Thu, 27 Aug 2026 20:01:10 -0700 Subject: [PATCH 06/22] =?UTF-8?q?docs(phase-J):=20j.2=20audit=20+=20gate?= =?UTF-8?q?=20update=20=E2=80=94=20sc-publish=20main=20@=2025668ec?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Track atm qualification in flight; wyvern pin hold until org receipt. Co-authored-by: Cursor --- .../.plan-hardening/j2-closeout-audit.md | 39 +++++++++++++++++++ .../.plan-hardening/j3-release-gate.md | 6 +-- .../.plan-hardening/upstream-tracking.md | 4 +- 3 files changed, 44 insertions(+), 5 deletions(-) create mode 100644 docs/plans/phase-J/.plan-hardening/j2-closeout-audit.md 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..33299f78 --- /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 | **open** | `manifests/r/randlee/wyvern` 404; v0.5.0 has legacy `wyvern-windows.zip` | +| 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 index d9c904de..14dd05b0 100644 --- a/docs/plans/phase-J/.plan-hardening/j3-release-gate.md +++ b/docs/plans/phase-J/.plan-hardening/j3-release-gate.md @@ -11,9 +11,9 @@ j.3 cannot start until every gate below is **green**. Do not merge | 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 | **pending** | After atm-core publish succeeds from `develop` | -| atm-core AT-style qualification + publish from `develop` | atm-core | **in progress** | v1.4.4 RC retry | -| Org pin published | atm/sc-publish | **pending** | Blessed SHA TBD after qualification | +| 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** | v1.4.4 RC retry (`chore/sc-publish-repin-25668ec` in CI); prior RC failed pre-fix @ run 33135181716 | +| Org pin published | atm/sc-publish | **pending** | Candidate `25668ec` (`main`); await atm qualification receipt | | wyvern `release/sc-publish-pin.toml` bumped + sync dry-run 0 | wyvern | **pending** | After org pin | ## Wyvern preflight gates (j.2 carryover) diff --git a/docs/plans/phase-J/.plan-hardening/upstream-tracking.md b/docs/plans/phase-J/.plan-hardening/upstream-tracking.md index c0c2ee4f..0736fe91 100644 --- a/docs/plans/phase-J/.plan-hardening/upstream-tracking.md +++ b/docs/plans/phase-J/.plan-hardening/upstream-tracking.md @@ -22,9 +22,9 @@ Each blocker is exactly one state: | Pin file | `release/sc-publish-pin.toml` | | Current revision | `42e0fcea23f730fae0ef3d08b060cd4df6a2602e` (atm-core AT.2) | | Sync entrypoint | `scripts/sync-sc-publish.sh` → isolated `.sc-publish-kit/` cache | -| Target org revision | `develop` @ `5d7c749` (PR #63 merge) after qualification + org pin | +| Target org revision | `25668ec` on `main` (PR #64) after qualification receipt | -**Note:** sc-publish `main` @ `43552e4` (#61 merged unqualified) is **not** wyvern's pin. Do not re-sync to `main` until org blessed release. +**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 From e599bc151f88d5e73d4150a1cfb32347a8c17a1c Mon Sep 17 00:00:00 2001 From: Rand Lee Date: Thu, 27 Aug 2026 20:01:57 -0700 Subject: [PATCH 07/22] 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 --- .../.plan-hardening/j2-closeout-audit.md | 2 +- .../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 ++++++++++++++++++ 5 files changed, 71 insertions(+), 1 deletion(-) 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 diff --git a/docs/plans/phase-J/.plan-hardening/j2-closeout-audit.md b/docs/plans/phase-J/.plan-hardening/j2-closeout-audit.md index 33299f78..05c98752 100644 --- a/docs/plans/phase-J/.plan-hardening/j2-closeout-audit.md +++ b/docs/plans/phase-J/.plan-hardening/j2-closeout-audit.md @@ -14,7 +14,7 @@ Evidence snapshot for sprint j.2 acceptance criteria on `integrate/phase-J` @ `8 | 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 | **open** | `manifests/r/randlee/wyvern` 404; v0.5.0 has legacy `wyvern-windows.zip` | +| 9 | Winget bootstrap if absent | **open** | Manifests staged in `release/winget-bootstrap/0.5.0/`; submit to `winget-pkgs` before j.3 | | 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 | 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" +``` From 832cc227b7a6d4b4d0172fb98e64f8bdb6724353 Mon Sep 17 00:00:00 2001 From: Rand Lee Date: Thu, 27 Aug 2026 20:05:36 -0700 Subject: [PATCH 08/22] docs(phase-J): winget bootstrap branch pushed; PR pending rate limit Co-authored-by: Cursor --- docs/plans/phase-J/.plan-hardening/j2-closeout-audit.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/plans/phase-J/.plan-hardening/j2-closeout-audit.md b/docs/plans/phase-J/.plan-hardening/j2-closeout-audit.md index 05c98752..2405fc55 100644 --- a/docs/plans/phase-J/.plan-hardening/j2-closeout-audit.md +++ b/docs/plans/phase-J/.plan-hardening/j2-closeout-audit.md @@ -14,7 +14,7 @@ Evidence snapshot for sprint j.2 acceptance criteria on `integrate/phase-J` @ `8 | 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 | **open** | Manifests staged in `release/winget-bootstrap/0.5.0/`; submit to `winget-pkgs` before j.3 | +| 9 | Winget bootstrap if absent | **in progress** | Branch `wyvern-bootstrap-0.5.0` pushed to `randlee/winget-pkgs`; [open PR](https://github.com/randlee/winget-pkgs/compare/wyvern-bootstrap-0.5.0?expand=1) to `microsoft/winget-pkgs` | | 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 | From 857383158f8cf1e8e9fbb88fefde842d3f12378f Mon Sep 17 00:00:00 2001 From: Rand Lee Date: Thu, 27 Aug 2026 20:06:50 -0700 Subject: [PATCH 09/22] 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 --- .../.plan-hardening/first-release-record.md | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 docs/plans/phase-J/.plan-hardening/first-release-record.md 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..7ae65db9 --- /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 | in progress | `randlee/winget-pkgs` branch `wyvern-bootstrap-0.5.0` | +| 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 | From 76d8046befe9ad66e3103fb6e4c5ff5eb4b14cf8 Mon Sep 17 00:00:00 2001 From: Rand Lee Date: Thu, 27 Aug 2026 20:12:41 -0700 Subject: [PATCH 10/22] docs(phase-J): winget #425477 submitted; re-pin draft PR #146 opened Co-authored-by: Cursor --- docs/plans/phase-J/.plan-hardening/first-release-record.md | 2 +- docs/plans/phase-J/.plan-hardening/j2-closeout-audit.md | 2 +- docs/plans/phase-J/.plan-hardening/j3-release-gate.md | 3 ++- 3 files changed, 4 insertions(+), 3 deletions(-) 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 7ae65db9..b97d4b91 100644 --- a/docs/plans/phase-J/.plan-hardening/first-release-record.md +++ b/docs/plans/phase-J/.plan-hardening/first-release-record.md @@ -13,7 +13,7 @@ Fill this during j.3 execution. j.4 go/no-go reads the final row. | 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 | in progress | `randlee/winget-pkgs` branch `wyvern-bootstrap-0.5.0` | +| 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 diff --git a/docs/plans/phase-J/.plan-hardening/j2-closeout-audit.md b/docs/plans/phase-J/.plan-hardening/j2-closeout-audit.md index 2405fc55..ede396c9 100644 --- a/docs/plans/phase-J/.plan-hardening/j2-closeout-audit.md +++ b/docs/plans/phase-J/.plan-hardening/j2-closeout-audit.md @@ -14,7 +14,7 @@ Evidence snapshot for sprint j.2 acceptance criteria on `integrate/phase-J` @ `8 | 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 | **in progress** | Branch `wyvern-bootstrap-0.5.0` pushed to `randlee/winget-pkgs`; [open PR](https://github.com/randlee/winget-pkgs/compare/wyvern-bootstrap-0.5.0?expand=1) to `microsoft/winget-pkgs` | +| 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 | diff --git a/docs/plans/phase-J/.plan-hardening/j3-release-gate.md b/docs/plans/phase-J/.plan-hardening/j3-release-gate.md index 14dd05b0..5b27114f 100644 --- a/docs/plans/phase-J/.plan-hardening/j3-release-gate.md +++ b/docs/plans/phase-J/.plan-hardening/j3-release-gate.md @@ -14,7 +14,8 @@ j.3 cannot start until every gate below is **green**. Do not merge | 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** | v1.4.4 RC retry (`chore/sc-publish-repin-25668ec` in CI); prior RC failed pre-fix @ run 33135181716 | | Org pin published | atm/sc-publish | **pending** | Candidate `25668ec` (`main`); await atm qualification receipt | -| wyvern `release/sc-publish-pin.toml` bumped + sync dry-run 0 | wyvern | **pending** | After org pin | +| Wyvern re-pin @ `25668ec` | wyvern | **draft** | [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) From 0572a0b3222da6f66347e6852bae00c8b85036f2 Mon Sep 17 00:00:00 2001 From: Rand Lee Date: Thu, 27 Aug 2026 20:38:07 -0700 Subject: [PATCH 11/22] docs(phase-J): atm-core #1069 CI green; wyvern #146 CI green Co-authored-by: Cursor --- docs/plans/phase-J/.plan-hardening/j3-release-gate.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/plans/phase-J/.plan-hardening/j3-release-gate.md b/docs/plans/phase-J/.plan-hardening/j3-release-gate.md index 5b27114f..72ad1f3a 100644 --- a/docs/plans/phase-J/.plan-hardening/j3-release-gate.md +++ b/docs/plans/phase-J/.plan-hardening/j3-release-gate.md @@ -12,9 +12,9 @@ j.3 cannot start until every gate below is **green**. Do not merge |------|-------|--------|----------| | 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** | v1.4.4 RC retry (`chore/sc-publish-repin-25668ec` in CI); prior RC failed pre-fix @ run 33135181716 | +| 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** | [PR #146](https://github.com/randlee/wyvern/pull/146) — merge after atm RC | +| 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) From 86cd85419af36efca2fab0b9b6288fe17a15f53b Mon Sep 17 00:00:00 2001 From: Rand Lee Date: Thu, 27 Aug 2026 20:39:46 -0700 Subject: [PATCH 12/22] 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 --- .claude/agents/publisher-channel-protocol.md | 4 + .claude/agents/publisher.md | 14 + .claude/skills/publishing/preflight.xml.j2 | 1 + .../publishing/ref/release-state-strategy.md | 12 + .cursor/agents/publisher.md | 4 + .github/scripts/bootstrap_sc_compose.py | 128 ++- .github/scripts/release_artifacts.py | 24 +- .github/scripts/release_gate.sh | 8 +- .github/scripts/release_manifest.py | 20 + .github/scripts/tests/test_install.py | 3 +- .../scripts/tests/test_publish_kit_assets.py | 7 +- .../scripts/tests/test_publish_kit_scripts.py | 121 ++- .../scripts/tests/test_release_artifacts.py | 809 +++++++++++++++++- .github/workflows/crates-publish.yml | 14 +- .github/workflows/homebrew-publish.yml | 23 +- .github/workflows/release-candidate.yml | 4 +- .github/workflows/release-preflight.yml | 97 ++- .github/workflows/release.yml | 83 +- .github/workflows/scoop-publish.yml | 16 +- .gitignore | 1 + README.sc-publish.md | 37 +- .../.plan-hardening/upstream-tracking.md | 10 +- release/sc-publish-pin.toml | 4 +- release/sc-publish-pin.toml.example | 13 + 24 files changed, 1259 insertions(+), 198 deletions(-) create mode 100644 release/sc-publish-pin.toml.example diff --git a/.claude/agents/publisher-channel-protocol.md b/.claude/agents/publisher-channel-protocol.md index f4c4b6b0..5464a513 100644 --- a/.claude/agents/publisher-channel-protocol.md +++ b/.claude/agents/publisher-channel-protocol.md @@ -12,6 +12,10 @@ 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 diff --git a/.claude/agents/publisher.md b/.claude/agents/publisher.md index 26c69fe8..3d26a87b 100644 --- a/.claude/agents/publisher.md +++ b/.claude/agents/publisher.md @@ -35,6 +35,9 @@ 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. +If a release task requires a direct template render, also read +`.claude/skills/publishing/ref/renderer-contract.md` before doing so. + ## Output Format Send the assignment's named recipient one concise ATM completion message @@ -182,6 +185,10 @@ exist; run `Release Preflight` and report its sanitized result. 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 @@ -193,6 +200,13 @@ exist; run `Release Preflight` and report its sanitized result. 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. diff --git a/.claude/skills/publishing/preflight.xml.j2 b/.claude/skills/publishing/preflight.xml.j2 index 279645eb..58880622 100644 --- a/.claude/skills/publishing/preflight.xml.j2 +++ b/.claude/skills/publishing/preflight.xml.j2 @@ -27,6 +27,7 @@ required_variables: {{ starting_state }} {{ preflight_stage }} {{ manifest_path }} + {{ already_published_channels | default('') }} .claude/skills/publishing/ref/release-state-strategy.md diff --git a/.claude/skills/publishing/ref/release-state-strategy.md b/.claude/skills/publishing/ref/release-state-strategy.md index a8917369..37eb82d1 100644 --- a/.claude/skills/publishing/ref/release-state-strategy.md +++ b/.claude/skills/publishing/ref/release-state-strategy.md @@ -13,6 +13,11 @@ the source of truth for artifacts, channels, and publish order. 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 @@ -64,3 +69,10 @@ 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/.cursor/agents/publisher.md b/.cursor/agents/publisher.md index 2ce8c055..60ddd230 100644 --- a/.cursor/agents/publisher.md +++ b/.cursor/agents/publisher.md @@ -33,6 +33,10 @@ Repository-specific data comes only from: - `.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`. diff --git a/.github/scripts/bootstrap_sc_compose.py b/.github/scripts/bootstrap_sc_compose.py index fa76a8eb..487a6457 100644 --- a/.github/scripts/bootstrap_sc_compose.py +++ b/.github/scripts/bootstrap_sc_compose.py @@ -4,16 +4,15 @@ from __future__ import annotations import argparse -import re import subprocess import sys from pathlib import Path -# This is an intentional historical compatibility floor, not a consumer's -# current workspace version. 1.4.1 is the first published wheel with the -# renderer features required by publish-kit templates. -SC_COMPOSE_VERSION = "1.4.1" +# 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: @@ -38,55 +37,110 @@ def installed_version(python: Path) -> str | None: return result.stdout.strip() if result.returncode == 0 else None -def version_components(value: str) -> tuple[int, ...]: - """Return numeric release components for a stable wheel version.""" - if not re.fullmatch(r"\d+(?:\.\d+)*", value): +def require_pinned_version(installed: str) -> None: + """Fail unless the managed wheel exactly matches the package contract.""" + if installed != SC_COMPOSE_VERSION: raise SystemExit( - "cannot verify managed sc-compose wheel version " - f"{value!r}; required >= {SC_COMPOSE_VERSION}" + "managed environment has incompatible sc-compose wheel: " + f"found {installed!r}; required exactly {SC_COMPOSE_VERSION}." ) - return tuple(int(component) for component in value.split(".")) -def require_version_floor(installed: str) -> None: - """Fail before downstream pytest can import a stale renderer binding.""" - if version_components(installed) < version_components(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: stale version " - f"{installed!r}; required >= {SC_COMPOSE_VERSION}. Use a new --venv path." + "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) - existing = installed_version(python) - if existing is None: - subprocess.run( - [ - str(python), - "-m", - "pip", - "install", - "--disable-pip-version-check", - f"sc-compose=={SC_COMPOSE_VERSION}", - ], - check=True, - stdout=sys.stderr, - ) - 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_version_floor(existing) + provision_pinned_wheel(python) + if args.write_cli: + write_cli_wrapper(venv, python) print(python) return 0 diff --git a/.github/scripts/release_artifacts.py b/.github/scripts/release_artifacts.py index aa9c857f..6af552c3 100644 --- a/.github/scripts/release_artifacts.py +++ b/.github/scripts/release_artifacts.py @@ -34,12 +34,12 @@ manifest_python_upload_tool, manifest_rust_toolchain, manifest_workspace_toml, - registry_version_state, 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]]: @@ -850,22 +850,6 @@ def cmd_cargo_build_bin_args(args: argparse.Namespace) -> int: return 0 -def cmd_check_version_unpublished(args: argparse.Namespace) -> int: - """Detect already-published crates via the contract's exact version_lookup_url.""" - manifest = load_manifest(Path(args.manifest), with_channel_contracts=True) - published = [] - for crate in manifest["crates"]: - check = _public_registry_checks( - manifest["channel_contracts"], "crates_io", crate["package"], args.version - )[0] - if registry_version_state(check["version_lookup_url"]) == "published": - 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 main() -> int: parser = argparse.ArgumentParser() sub = parser.add_subparsers(dest="cmd", required=True) @@ -942,6 +926,11 @@ def main() -> int: 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) @@ -989,6 +978,7 @@ def main() -> int: 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() diff --git a/.github/scripts/release_gate.sh b/.github/scripts/release_gate.sh index dc42f554..de3ad33d 100755 --- a/.github/scripts/release_gate.sh +++ b/.github/scripts/release_gate.sh @@ -7,6 +7,7 @@ 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 @@ -50,10 +51,15 @@ fi python3 .github/scripts/release_artifacts.py check-version-unpublished \ --manifest "$MANIFEST" \ - --version "$VERSION" >/dev/null + --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 index 7b0cd3f5..6c0e849c 100644 --- a/.github/scripts/release_manifest.py +++ b/.github/scripts/release_manifest.py @@ -516,6 +516,26 @@ def registry_version_state(url: str, timeout: int = 20) -> str: 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] diff --git a/.github/scripts/tests/test_install.py b/.github/scripts/tests/test_install.py index a6312e60..6966a8ae 100644 --- a/.github/scripts/tests/test_install.py +++ b/.github/scripts/tests/test_install.py @@ -328,7 +328,8 @@ def render_empty_toml(_template: Path, _values: dict[str, object], output: Path) ) self.assertIn(".github/scripts/release_gate.sh", release) self.assertNotIn("run: scripts/release_gate.sh", release) - self.assertIn('git tag -a "${candidate_tag}" origin/develop', release_candidate) + 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) diff --git a/.github/scripts/tests/test_publish_kit_assets.py b/.github/scripts/tests/test_publish_kit_assets.py index 25b8e34e..1519bc7d 100644 --- a/.github/scripts/tests/test_publish_kit_assets.py +++ b/.github/scripts/tests/test_publish_kit_assets.py @@ -41,7 +41,12 @@ def test_release_candidate_workflow_and_shared_policy_require_provenance(self) - encoding="utf-8" ) policy = (PUBLISHING / "ref" / "release-state-strategy.md").read_text(encoding="utf-8") - self.assertIn("git tag -a \"${candidate_tag}\" origin/develop", workflow) + 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) diff --git a/.github/scripts/tests/test_publish_kit_scripts.py b/.github/scripts/tests/test_publish_kit_scripts.py index 705943cd..6fe8dca8 100644 --- a/.github/scripts/tests/test_publish_kit_scripts.py +++ b/.github/scripts/tests/test_publish_kit_scripts.py @@ -3,11 +3,14 @@ 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()) @@ -156,6 +159,14 @@ def test_release_gate_accepts_main_with_post_cut_develop_drift(self) -> None: 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( [ @@ -169,13 +180,16 @@ def test_release_gate_accepts_main_with_post_cut_develop_drift(self) -> None: "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: @@ -250,31 +264,114 @@ def test_release_artifacts_cli_exposes_read_only_inquiry(self) -> None: 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_documented_renderer_version_floor(self) -> None: + 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 version_components")] - self.assertIn('SC_COMPOSE_VERSION = "1.4.1"', text) - self.assertIn("historical compatibility floor", text) + 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.assertNotIn("existing != SC_COMPOSE_VERSION", text) - self.assertIn("require_version_floor(existing)", text) + 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_a_too_old_wheel(self) -> None: + 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"stale version '1\.4\.0'; required >= 1\.4\.1", + r"found '1\.5\.1'; required exactly 1\.5\.0", ): - BOOTSTRAP.require_version_floor("1.4.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_accepts_the_floor_and_newer_wheels(self) -> None: - BOOTSTRAP.require_version_floor("1.4.1") - BOOTSTRAP.require_version_floor("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__": diff --git a/.github/scripts/tests/test_release_artifacts.py b/.github/scripts/tests/test_release_artifacts.py index 962afec1..7bf116c4 100644 --- a/.github/scripts/tests/test_release_artifacts.py +++ b/.github/scripts/tests/test_release_artifacts.py @@ -2,13 +2,16 @@ 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 @@ -235,12 +238,65 @@ def test_release_artifact_cli_stays_below_the_script_line_ceiling() -> None: ).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") @@ -265,6 +321,366 @@ 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" @@ -454,6 +870,30 @@ def test_crates_leg_is_separate_and_independently_retryable() -> None: 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() @@ -496,6 +936,8 @@ def test_no_single_repo_concerns_leak_into_kit_workflows_actions_or_scripts() -> "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", @@ -509,8 +951,10 @@ def test_no_single_repo_concerns_leak_into_kit_workflows_actions_or_scripts() -> ) kit_actions = ( "extract-published-renderer", + "install-linux-native-deps", "setup-lint-toolchain", "setup-python-release-build", + "setup-renderer", "setup-sc-lint", "verify-published-release", ) @@ -518,6 +962,7 @@ def test_no_single_repo_concerns_leak_into_kit_workflows_actions_or_scripts() -> "bootstrap_sc_compose.py", "release_artifacts.py", "release_manifest.py", + "release_registry.py", "release_gate.sh", ) github_root = repo_root() / ".github" @@ -674,6 +1119,7 @@ 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): @@ -682,9 +1128,13 @@ def test_crates_already_published_detection_uses_exact_version_lookup() -> None: 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 "registry_version_state" 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 @@ -785,6 +1235,15 @@ def channel_config(tag: str) -> dict: 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( @@ -1122,7 +1581,6 @@ def test_manifest_drives_non_disclosing_preflight_secret_plan() -> None: {"environment": "testpypi", "name": "TEST_PYPI_API_TOKEN"}, ] assert plan["liveness_channel_checks"] == [ - {"channel": "crates_io", "name": "CARGO_REGISTRY_TOKEN", "kind": "crates_io"}, {"channel": "homebrew", "name": "HOMEBREW_TAP_TOKEN", "kind": "github"}, {"channel": "winget", "name": "WINGET_GITHUB_TOKEN", "kind": "github"}, {"channel": "scoop", "name": "SCOOP_BUCKET_TOKEN", "kind": "github"}, @@ -1140,6 +1598,7 @@ def test_manifest_drives_non_disclosing_preflight_secret_plan() -> None: "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"] == { @@ -1556,6 +2015,27 @@ def test_public_registry_inquiry_plan_is_contract_derived_and_read_only() -> Non 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() @@ -1622,16 +2102,294 @@ def test_release_preflight_requires_each_standardized_secret() -> None: assert "preflight-secret-plan" in text assert '--manifest "${RELEASE_ARTIFACT_MANIFEST}"' in text assert '\\"${RELEASE_ARTIFACT_MANIFEST}\\"' not in text - assert "Verify protected Python environment secret metadata" 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" 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" 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: @@ -1665,7 +2423,8 @@ def test_channel_recovery_workflows_require_a_published_release() -> None: 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/extract-published-renderer" 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 @@ -1675,7 +2434,8 @@ def test_channel_recovery_workflows_require_a_published_release() -> None: 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/extract-published-renderer" 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 @@ -1683,13 +2443,26 @@ def test_channel_recovery_workflows_require_a_published_release() -> None: repo_root() / ".github" / "actions" - / "extract-published-renderer" + / "setup-renderer" / "action.yml" ).read_text(encoding="utf-8") - assert "binary-path" in renderer_action - assert "Published renderer archive is missing ${RENDERER_BINARY_PATH}" in renderer_action + 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] @@ -1832,12 +2605,13 @@ def test_publish_kit_guidance_is_manifest_driven_and_token_non_disclosing() -> N repo_root() / ".claude" / "skills" / "publishing" / "evals" / "channel-name-inquiry.md" ).read_text(encoding="utf-8") - for text in (publisher_text, guide_text, checklist_text): + 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" @@ -2010,6 +2784,7 @@ def test_publishing_task_templates_render_recipient_contract(tmp_path: Path) -> "worktree_path": "/tmp/eval", "branch": "develop", "manifest_path": "release/publish-artifacts.toml", + "already_published_channels": "crates_io", }, ), ( @@ -2034,6 +2809,8 @@ def test_publishing_task_templates_render_recipient_contract(tmp_path: Path) -> 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: @@ -2051,6 +2828,10 @@ def test_release_preflight_collects_independent_failures_before_denial() -> None 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 @@ -2069,7 +2850,7 @@ def test_release_workflow_rehearsal_mode_avoids_production_side_effects() -> Non text = release_workflow_text() assert 'echo "Rehearsal mode: validating release tag ${tag} locally only; not pushing any tag to origin"' in text - assert "echo \"release_ref=$main_sha\" >> \"$GITHUB_OUTPUT\"" in text + assert text.count("echo \"build_ref=$main_sha\" >> \"$GITHUB_OUTPUT\"") == 1 assert "needs.gate-and-tag.outputs.release_target == 'production'" in text @@ -2086,7 +2867,7 @@ def test_release_workflow_checks_out_repo_before_local_python_setup_action() -> steps: - uses: actions/checkout@v4 with: - ref: ${{ needs.gate-and-tag.outputs.release_ref }} + 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' }} @@ -2098,7 +2879,7 @@ def test_release_workflow_checks_out_repo_before_local_python_setup_action() -> steps: - uses: actions/checkout@v4 with: - ref: ${{ needs.gate-and-tag.outputs.release_ref }} + ref: ${{ needs.gate-and-tag.outputs.build_ref }} - uses: ./.github/actions/setup-python-release-build""" assert wheels_job in text diff --git a/.github/workflows/crates-publish.yml b/.github/workflows/crates-publish.yml index f711da37..86d7624e 100644 --- a/.github/workflows/crates-publish.yml +++ b/.github/workflows/crates-publish.yml @@ -57,6 +57,9 @@ jobs: 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: @@ -81,13 +84,12 @@ jobs: 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')" - status="$(curl --location --max-time 20 --silent --show-error --output /dev/null \ - --write-out '%{http_code}' --user-agent 'sc-publish-kit' "${url}" || true)" - case "${status}" in - 200) return 0 ;; - 404) return 1 ;; + 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 (status ${status})" >&2 + echo "crates.io state for ${crate}@${version} is indeterminate (state ${state})" >&2 exit 1 ;; esac diff --git a/.github/workflows/homebrew-publish.yml b/.github/workflows/homebrew-publish.yml index 237963f3..1d03e629 100644 --- a/.github/workflows/homebrew-publish.yml +++ b/.github/workflows/homebrew-publish.yml @@ -67,6 +67,8 @@ jobs: 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: @@ -96,9 +98,7 @@ jobs: config = json.loads(os.environ["CHANNEL_CONFIG"]) project = config["project"] - channel = config["channel"] assets = {} - archives_by_target = {} def download(target): filename = f"{project['archive_prefix']}_{os.environ['RELEASE_VERSION']}_{target['target']}.{target['archive']}" @@ -108,7 +108,6 @@ jobs: ["curl", "-fsSL", "--retry", "5", "--retry-delay", "30", "--retry-all-errors", "-o", str(path), url], check=True, ) - archives_by_target[target["target"]] = path return url, path for asset in config["channel"]["assets"]: @@ -125,24 +124,8 @@ jobs: raise SystemExit( "Homebrew assets must contain exactly: macos_arm, macos_intel, linux" ) - renderer_target = config["release_targets"][channel["renderer_target"]] - renderer_archive = archives_by_target.get(renderer_target["target"]) - if renderer_archive is None: - _url, renderer_archive = download(renderer_target) Path("homebrew-release-assets.json").write_text(json.dumps(assets), encoding="utf-8") - with Path(os.environ["GITHUB_ENV"]).open("a", encoding="utf-8") as env_file: - env_file.write(f"PUBLISHED_RENDERER_ARCHIVE={renderer_archive}\n") - env_file.write( - "PUBLISHED_RENDERER_EXTRACT_DIR=" - f"{project['archive_prefix']}_{os.environ['RELEASE_VERSION']}_{renderer_target['target']}\n" - ) PY - - name: Extract manifest-declared published renderer - uses: ./.github/actions/extract-published-renderer - with: - archive: ${{ env.PUBLISHED_RENDERER_ARCHIVE }} - extract-dir: ${{ env.PUBLISHED_RENDERER_EXTRACT_DIR }} - binary-path: ${{ fromJSON(needs.verify-release.outputs.channel_config).project.renderer_archive_path }} - name: Render manifest-selected formulas with the published renderer shell: bash env: @@ -159,7 +142,7 @@ jobs: config = json.loads(os.environ["CHANNEL_CONFIG"]) project = config["project"] channel = config["channel"] - assets = {asset["key"]: asset for asset in json.loads(Path("homebrew-release-assets.json").read_text())} + 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"] diff --git a/.github/workflows/release-candidate.yml b/.github/workflows/release-candidate.yml index 8b0f8390..ef0bd49e 100644 --- a/.github/workflows/release-candidate.yml +++ b/.github/workflows/release-candidate.yml @@ -54,7 +54,9 @@ jobs: || { 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 tag -a "${candidate_tag}" origin/develop -m "Release candidate ${release_tag}" + 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})." diff --git a/.github/workflows/release-preflight.yml b/.github/workflows/release-preflight.yml index c7d685be..f2d780bb 100644 --- a/.github/workflows/release-preflight.yml +++ b/.github/workflows/release-preflight.yml @@ -12,10 +12,16 @@ 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: - actions: read - contents: read +# `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: @@ -97,7 +103,7 @@ jobs: echo "All manifest-required repository secrets are available." - id: environment_secrets - name: Verify protected Python environment secret metadata + name: Inspect protected Python environment secret metadata (informational) if: ${{ always() && steps.secret_plan.outcome == 'success' }} continue-on-error: true shell: bash @@ -108,9 +114,10 @@ jobs: 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 - failures+=("Could not inspect secret metadata for protected environment ${environment_name}.") + 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 @@ -119,7 +126,11 @@ jobs: printf '%s\n' "${failures[@]}" >&2 exit 1 fi - echo "All manifest-required protected-environment secret names are present." + 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 @@ -152,7 +163,6 @@ jobs: 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 }} @@ -164,9 +174,6 @@ jobs: token="${!secret_name:-}" channel_status=success case "${check_kind}" in - crates_io) - command=(curl --fail --silent --show-error --header "Authorization: ${token}" https://crates.io/api/v1/me) - ;; github) command=(curl --fail --silent --show-error --header "Authorization: Bearer ${token}" --header "Accept: application/vnd.github+json" https://api.github.com/user) ;; @@ -177,7 +184,7 @@ jobs: ;; esac if ! "${command[@]}" >/dev/null; then - failures+=("GitHub Actions secret ${secret_name} is present but rejected by its publish service; rotate or replace it.") + 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}")" @@ -205,6 +212,10 @@ jobs: 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 @@ -283,59 +294,71 @@ jobs: 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 }}' + '${{ 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 }}' + --version '${{ steps.meta.outputs.release_version }}' \ + --already-published-channels "${ALREADY_PUBLISHED_CHANNELS}" - 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 failures=() - status_code() { - local status - status="$(curl --location --max-time 20 --silent --show-error --output /dev/null \ - --write-out '%{http_code}' "$1" || true)" - case "${status}" in - 200|404) printf '%s' "${status}" ;; - *) printf '000' ;; - esac + 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_status="$(status_code "${project_url}")" - version_status="$(status_code "${version_url}")" - case "${project_status}:${version_status}:${version_policy}" in - 404:404:*) + 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}" ;; - 200:404:*) + published:absent:*) printf '%s: %s project %s exists; version %s is not published.\n' \ "${agent}" "${registry}" "${normalized_name}" "${expected_version}" ;; - 200:200:must_be_absent) - failures+=("${channel}/${registry}: ${normalized_name}@${expected_version} is already published.") + 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 ;; - 200:200:informational) + 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_status}, version=${version_status}).") + 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 \ @@ -391,14 +414,22 @@ jobs: 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:-{}}" \ + --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:-{}}" \ + --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}')" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c66b1037..826086a5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -20,6 +20,11 @@ on: 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 @@ -34,7 +39,7 @@ jobs: release_tag: ${{ steps.meta.outputs.release_tag }} release_version: ${{ steps.meta.outputs.release_version }} release_target: ${{ steps.meta.outputs.release_target }} - release_ref: ${{ steps.release-ref.outputs.release_ref }} + build_ref: ${{ steps.release-ref.outputs.build_ref }} steps: - uses: actions/checkout@v4 with: @@ -91,7 +96,10 @@ jobs: --workspace-toml '${{ steps.build_plan.outputs.workspace_toml }}' - name: Run release gate - 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 }}' + 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 @@ -101,25 +109,29 @@ jobs: git fetch origin --prune --tags tag='${{ steps.meta.outputs.release_tag }}' target='${{ steps.meta.outputs.release_target }}' - main_sha="$(git rev-parse origin/main)" + 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 - remote_tag_sha="$(git ls-remote --tags origin "refs/tags/${tag}" | awk '{print $1}' || true)" - if [[ -n "$remote_tag_sha" ]]; then - if [[ "$remote_tag_sha" == "$main_sha" ]]; then - echo "Tag ${tag} already exists and points to origin/main (${main_sha}), skipping creation" - echo "release_ref=$tag" >> "$GITHUB_OUTPUT" - exit 0 + 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 - echo "Tag ${tag} exists but points to ${remote_tag_sha}, not origin/main (${main_sha})" >&2 - exit 1 + 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 - git tag "$tag" origin/main - git push origin "$tag" - echo "release_ref=$tag" >> "$GITHUB_OUTPUT" - exit 0 + else + echo "Rehearsal mode: validating release tag ${tag} locally only; not pushing any tag to origin" fi - echo "Rehearsal mode: validating release tag ${tag} locally only; not pushing any tag to origin" - echo "release_ref=$main_sha" >> "$GITHUB_OUTPUT" + echo "build_ref=$main_sha" >> "$GITHUB_OUTPUT" build: needs: [gate-and-tag, release-plan] @@ -130,7 +142,7 @@ jobs: steps: - uses: actions/checkout@v4 with: - ref: ${{ needs.gate-and-tag.outputs.release_ref }} + ref: ${{ needs.gate-and-tag.outputs.build_ref }} - name: Install Rust toolchain uses: dtolnay/rust-toolchain@master @@ -141,6 +153,9 @@ jobs: - name: Ensure cross-compilation target run: rustup target add ${{ matrix.target }} + - name: Install Linux native build deps + uses: ./.github/actions/install-linux-native-deps + - name: Cache cargo uses: actions/cache@v4 with: @@ -193,7 +208,7 @@ jobs: shutil.rmtree(stage) (stage / "bin").mkdir(parents=True) for binary in config["binaries"]: - filename = binary["name"] + (".exe" if target_name.endswith("windows-msvc") else "") + 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"]) @@ -229,13 +244,16 @@ jobs: steps: - uses: actions/checkout@v4 with: - ref: ${{ needs.gate-and-tag.outputs.release_ref }} + ref: ${{ needs.gate-and-tag.outputs.build_ref }} - name: Install Rust toolchain uses: dtolnay/rust-toolchain@master with: toolchain: ${{ needs.release-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: @@ -259,13 +277,12 @@ jobs: 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')" - status="$(curl --location --max-time 20 --silent --show-error --output /dev/null \ - --write-out '%{http_code}' --user-agent 'sc-publish-kit' "${url}" || true)" - case "${status}" in - 200) return 0 ;; - 404) return 1 ;; + 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 (status ${status})" >&2 + echo "crates.io state for ${crate}@${version} is indeterminate (state ${state})" >&2 exit 1 ;; esac @@ -304,7 +321,7 @@ jobs: steps: - uses: actions/checkout@v4 with: - ref: ${{ needs.gate-and-tag.outputs.release_ref }} + ref: ${{ needs.gate-and-tag.outputs.build_ref }} - id: manifest name: Read Python distribution matrices from release manifest shell: bash @@ -333,10 +350,10 @@ jobs: steps: - uses: actions/checkout@v4 with: - ref: ${{ needs.gate-and-tag.outputs.release_ref }} + ref: ${{ needs.gate-and-tag.outputs.build_ref }} - uses: ./.github/actions/setup-python-release-build with: - release_ref: ${{ needs.gate-and-tag.outputs.release_ref }} + 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 }} @@ -365,10 +382,10 @@ jobs: steps: - uses: actions/checkout@v4 with: - ref: ${{ needs.gate-and-tag.outputs.release_ref }} + ref: ${{ needs.gate-and-tag.outputs.build_ref }} - uses: ./.github/actions/setup-python-release-build with: - release_ref: ${{ needs.gate-and-tag.outputs.release_ref }} + 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 }} @@ -402,7 +419,7 @@ jobs: steps: - uses: actions/checkout@v4 with: - ref: ${{ needs.gate-and-tag.outputs.release_ref }} + ref: ${{ needs.gate-and-tag.outputs.build_ref }} - uses: actions/download-artifact@v4 with: pattern: 'python-wheel-*' @@ -458,7 +475,7 @@ jobs: steps: - uses: actions/checkout@v4 with: - ref: ${{ needs.gate-and-tag.outputs.release_tag }} + ref: ${{ needs.gate-and-tag.outputs.build_ref }} - id: expected_assets name: Read expected release asset patterns from manifest diff --git a/.github/workflows/scoop-publish.yml b/.github/workflows/scoop-publish.yml index cf0fdaf0..d10ea7b8 100644 --- a/.github/workflows/scoop-publish.yml +++ b/.github/workflows/scoop-publish.yml @@ -68,6 +68,8 @@ jobs: 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: @@ -110,8 +112,6 @@ jobs: target = config["release_targets"][channel["installer_target"]] url, archive = download(target) - renderer_target = config["release_targets"][channel["renderer_target"]] - _renderer_url, renderer_archive = download(renderer_target) variables = { "version": os.environ["RELEASE_VERSION"], "description": project["description"], @@ -123,19 +123,7 @@ jobs: "binary": channel["binary"], } Path("scoop-vars.json").write_text(json.dumps(variables), encoding="utf-8") - with Path(os.environ["GITHUB_ENV"]).open("a", encoding="utf-8") as env_file: - env_file.write(f"PUBLISHED_RENDERER_ARCHIVE={renderer_archive}\n") - env_file.write( - "PUBLISHED_RENDERER_EXTRACT_DIR=" - f"{project['archive_prefix']}_{os.environ['RELEASE_VERSION']}_{renderer_target['target']}\n" - ) PY - - name: Extract manifest-declared published renderer - uses: ./.github/actions/extract-published-renderer - with: - archive: ${{ env.PUBLISHED_RENDERER_ARCHIVE }} - extract-dir: ${{ env.PUBLISHED_RENDERER_EXTRACT_DIR }} - binary-path: ${{ fromJSON(needs.verify-release.outputs.channel_config).project.renderer_archive_path }} - name: Render Scoop manifest with published renderer shell: bash env: diff --git a/.gitignore b/.gitignore index 8153d1e4..9267198b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ /target +/crates/wyvern/target /dist Cargo.lock **/*.rs.bk diff --git a/README.sc-publish.md b/README.sc-publish.md index 84bcc5ff..ccdf5959 100644 --- a/README.sc-publish.md +++ b/README.sc-publish.md @@ -17,7 +17,7 @@ customization mechanism. Installation is three commands, run from the consumer repository root: ```bash -# 1. Provision the pinned sc-compose renderer bindings into a virtualenv. +# 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 @@ -37,6 +37,36 @@ 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, @@ -73,6 +103,11 @@ Each publish channel — `github_release`, `crates_io`, `pypi`, `homebrew`, - 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 diff --git a/docs/plans/phase-J/.plan-hardening/upstream-tracking.md b/docs/plans/phase-J/.plan-hardening/upstream-tracking.md index 0736fe91..add6a1ba 100644 --- a/docs/plans/phase-J/.plan-hardening/upstream-tracking.md +++ b/docs/plans/phase-J/.plan-hardening/upstream-tracking.md @@ -4,9 +4,9 @@ Each blocker is exactly one state: | Blocker | State | PR / commit | Signed waiver | Effect | |---------|-------|-------------|---------------|--------| -| CR-001 Linux webview deps | **pending** | sc-publish [PR #63](https://github.com/randlee/sc-publish/pull/63) (includes #61) | | Awaiting org-wide qualification before wyvern re-pin. Wyvern stays on `42e0fce` until blessed SHA published. | -| CR-002 Homebrew/Scoop renderer | **pending** | same as CR-001 | | Same gate. #61 `setup-renderer` path must pass atm-core AT-style qualification. | -| CR-RC Git identity on RC tag | **pending** | PR #63 (supersedes #62) | | atm-core v1.4.4 RC failed without this; included in PR #63. | +| CR-001 Linux webview deps | **ready** | sc-publish `main` @ `25668ec` | | Merge wyvern re-pin after atm RC receipt | +| CR-002 Homebrew/Scoop renderer | **ready** | same | | same | +| CR-RC Git identity on RC tag | **ready** | `release-candidate.yml` @ `25668ec` | | same | **Rules:** @@ -20,9 +20,9 @@ Each blocker is exactly one state: | Item | Value | |------|-------| | Pin file | `release/sc-publish-pin.toml` | -| Current revision | `42e0fcea23f730fae0ef3d08b060cd4df6a2602e` (atm-core AT.2) | +| 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` on `main` (PR #64) after qualification receipt | +| 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. diff --git a/release/sc-publish-pin.toml b/release/sc-publish-pin.toml index 324df007..c2c09550 100644 --- a/release/sc-publish-pin.toml +++ b/release/sc-publish-pin.toml @@ -1,5 +1,5 @@ # Pinned sc-publish kit revision for wyvern (consumer-owned). # Must match the org-wide qualified kit SHA — do not advance unilaterally. -# atm-core AT.2 receipt: 42e0fce (2026-08-27). +# org-blessed kit revision (sc-publish main @ 25668ec, PR #64). repository = "https://github.com/randlee/sc-publish.git" -revision = "42e0fcea23f730fae0ef3d08b060cd4df6a2602e" +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" From c6a8a508d2b8a2a89750626f9c0cc1dbad26c8e1 Mon Sep 17 00:00:00 2001 From: Rand Lee Date: Thu, 27 Aug 2026 20:40:48 -0700 Subject: [PATCH 13/22] 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 --- Cargo.toml | 8 ++++---- docs/plans/phase-J/.plan-hardening/j3-release-gate.md | 2 +- docs/plans/phase-J/.plan-hardening/upstream-tracking.md | 6 +++--- 3 files changed, 8 insertions(+), 8 deletions(-) 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/docs/plans/phase-J/.plan-hardening/j3-release-gate.md b/docs/plans/phase-J/.plan-hardening/j3-release-gate.md index 72ad1f3a..f0494f98 100644 --- a/docs/plans/phase-J/.plan-hardening/j3-release-gate.md +++ b/docs/plans/phase-J/.plan-hardening/j3-release-gate.md @@ -23,7 +23,7 @@ j.3 cannot start until every gate below is **green**. Do not merge |------|--------|----------| | Secrets present (`WINGET_*`, `SCOOP_*`, …) | **done** | `gh secret list` | | `randlee/scoop-bucket` cloneable | **done** | Public repo | -| `randlee.wyvern` winget bootstrap | **open** | `manifests/r/randlee/wyvern` 404 | +| `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) diff --git a/docs/plans/phase-J/.plan-hardening/upstream-tracking.md b/docs/plans/phase-J/.plan-hardening/upstream-tracking.md index add6a1ba..2f02f301 100644 --- a/docs/plans/phase-J/.plan-hardening/upstream-tracking.md +++ b/docs/plans/phase-J/.plan-hardening/upstream-tracking.md @@ -4,9 +4,9 @@ Each blocker is exactly one state: | Blocker | State | PR / commit | Signed waiver | Effect | |---------|-------|-------------|---------------|--------| -| CR-001 Linux webview deps | **ready** | sc-publish `main` @ `25668ec` | | Merge wyvern re-pin after atm RC receipt | -| CR-002 Homebrew/Scoop renderer | **ready** | same | | same | -| CR-RC Git identity on RC tag | **ready** | `release-candidate.yml` @ `25668ec` | | same | +| 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:** From deb62bef1797672319e4ad3888ba8a62a9c1ca7a Mon Sep 17 00:00:00 2001 From: Rand Lee Date: Thu, 27 Aug 2026 20:53:17 -0700 Subject: [PATCH 14/22] docs(phase-J-j3): org gates green; RC blocked on PR #148 workflow registration Co-authored-by: Cursor --- .../plans/phase-J/.plan-hardening/j3-release-gate.md | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/docs/plans/phase-J/.plan-hardening/j3-release-gate.md b/docs/plans/phase-J/.plan-hardening/j3-release-gate.md index f0494f98..844180d7 100644 --- a/docs/plans/phase-J/.plan-hardening/j3-release-gate.md +++ b/docs/plans/phase-J/.plan-hardening/j3-release-gate.md @@ -1,6 +1,6 @@ # j.3 release gate (Phase J) -**Status:** blocked (org qualification in flight) +**Status:** RC dispatch blocked — register workflow on `main` ([PR #148](https://github.com/randlee/wyvern/pull/148)) **Updated:** 2026-08-28 j.3 cannot start until every gate below is **green**. Do not merge @@ -12,10 +12,12 @@ j.3 cannot start until every gate below is **green**. Do not merge |------|-------|--------|----------| | 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 | +| atm-core AT-style qualification + publish from `develop` | atm-core | **done** | #1069 merged; RC [33139520613](https://github.com/randlee/atm-core/actions/runs/33139520613) success | +| Org pin published | atm/sc-publish | **done** | `25668ec` on sc-publish `main`; atm + wyvern pinned | +| Wyvern re-pin @ `25668ec` | wyvern | **done** | [PR #146](https://github.com/randlee/wyvern/pull/146) merged | +| wyvern sync dry-run @ blessed SHA | wyvern | **done** | exit 0 @ `25668ec` | +| `develop` has phase-J @ 0.6.0 | wyvern | **done** | [PR #147](https://github.com/randlee/wyvern/pull/147) merged | +| RC workflow dispatchable | wyvern | **blocked** | [PR #148](https://github.com/randlee/wyvern/pull/148) needs merge (main default branch) | ## Wyvern preflight gates (j.2 carryover) From f255f48e46b815545271cd5617da934e420904a0 Mon Sep 17 00:00:00 2001 From: Rand Lee Date: Thu, 27 Aug 2026 20:53:25 -0700 Subject: [PATCH 15/22] docs(phase-J): upstream-tracking reflects org pin @ 25668ec Co-authored-by: Cursor --- docs/plans/phase-J/.plan-hardening/upstream-tracking.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/plans/phase-J/.plan-hardening/upstream-tracking.md b/docs/plans/phase-J/.plan-hardening/upstream-tracking.md index 2f02f301..edec1bf7 100644 --- a/docs/plans/phase-J/.plan-hardening/upstream-tracking.md +++ b/docs/plans/phase-J/.plan-hardening/upstream-tracking.md @@ -20,11 +20,11 @@ Each blocker is exactly one state: | 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` | +| Current revision | `25668ecc164261be676c9414c4f603b18ab74c91` (org blessed, PR #146) | | 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) | +| Target org revision | `25668ec` — qualified via atm-core #1069 + RC 33139520613 | -**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. +**Note:** Org pin active on wyvern @ `25668ec`. j.3 RC dispatch blocked until [PR #148](https://github.com/randlee/wyvern/pull/148) registers workflow on `main` (default branch) or default branch switches to `develop`. ## j.2 closeout extras From 5c2e5c4724174110619587fe793a766d35287fd7 Mon Sep 17 00:00:00 2001 From: Rand Lee Date: Thu, 27 Aug 2026 21:06:54 -0700 Subject: [PATCH 16/22] docs(phase-J-j3): RC runbook; first-release-record gates updated Blocked on PR #148 review to register RC workflow on main default branch. Co-authored-by: Cursor --- .../.plan-hardening/first-release-record.md | 17 ++++----- .../phase-J/.plan-hardening/j3-rc-runbook.md | 36 +++++++++++++++++++ 2 files changed, 45 insertions(+), 8 deletions(-) create mode 100644 docs/plans/phase-J/.plan-hardening/j3-rc-runbook.md 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..f94221ff 100644 --- a/docs/plans/phase-J/.plan-hardening/first-release-record.md +++ b/docs/plans/phase-J/.plan-hardening/first-release-record.md @@ -1,20 +1,21 @@ # 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:** blocked on [PR #148](https://github.com/randlee/wyvern/pull/148) review +**Target version:** `0.6.0` +**Branch:** `develop` (phase-J merged via #147) → `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 | **blocked** | PR #148 CI green; review required | ## State machine diff --git a/docs/plans/phase-J/.plan-hardening/j3-rc-runbook.md b/docs/plans/phase-J/.plan-hardening/j3-rc-runbook.md new file mode 100644 index 00000000..1fe39d74 --- /dev/null +++ b/docs/plans/phase-J/.plan-hardening/j3-rc-runbook.md @@ -0,0 +1,36 @@ +# j.3 RC runbook (post PR #148) + +**Blocked on:** [PR #148](https://github.com/randlee/wyvern/pull/148) merge (1 approving review; CI green). + +Wyvern default branch is `main`; GitHub only registers `workflow_dispatch` workflows on the default branch. PR #148 copies `release-candidate.yml` to `main` so the RC can be dispatched against `develop`. + +## After #148 merges + +```bash +# 1. RC cut (tags origin/develop) +gh workflow run "Release Candidate" --repo randlee/wyvern --ref develop -f version=0.6.0 +gh run list --repo randlee/wyvern --workflow "Release Candidate" --limit 1 + +# 2. Release branch from candidate tag +git fetch origin release-candidate-v0.6.0 +git checkout -b release/v0.6.0 release-candidate-v0.6.0 +# … version lockstep commits if needed … +git push origin release/v0.6.0 +gh pr create --base main --head release/v0.6.0 --title "release: v0.6.0" + +# 3. After merge to main +gh workflow run release-preflight.yml --repo randlee/wyvern --ref main -f tag=v0.6.0 +gh workflow run release.yml --repo randlee/wyvern --ref main -f tag=v0.6.0 -f target=production + +# 4. Post-release legs (per channel-dispatch-plan) +gh workflow run homebrew-publish.yml --repo randlee/wyvern --ref main -f tag=v0.6.0 +gh workflow run scoop-publish.yml --repo randlee/wyvern --ref main -f tag=v0.6.0 +gh workflow run winget-publish.yml --repo randlee/wyvern --ref main -f tag=v0.6.0 +gh workflow run crates-publish.yml --repo randlee/wyvern --ref main -f tag=v0.6.0 +``` + +Record run IDs in [first-release-record.md](first-release-record.md). + +## Alternative (org policy) + +Set repo default branch to `develop` (atm-core model) — requires admin on `randlee/wyvern`. Then #148 is unnecessary. From 220ecd740814246405af350a61f0e86b934cb494 Mon Sep 17 00:00:00 2001 From: Rand Lee Date: Thu, 27 Aug 2026 21:09:23 -0700 Subject: [PATCH 17/22] docs(phase-J-j3): PR #148 merged; RC v0.6.0 run 33140961859 success Co-authored-by: Cursor --- docs/plans/phase-J/.plan-hardening/first-release-record.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 f94221ff..b2d81b61 100644 --- a/docs/plans/phase-J/.plan-hardening/first-release-record.md +++ b/docs/plans/phase-J/.plan-hardening/first-release-record.md @@ -15,7 +15,7 @@ See [j3-rc-runbook.md](j3-rc-runbook.md) for dispatch commands after #148 merges | 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 @ `25668ec` | -| RC workflow dispatchable | **blocked** | PR #148 CI green; review required | +| RC workflow dispatchable | **done** | PR #148 merged; RC [33140961859](https://github.com/randlee/wyvern/actions/runs/33140961859) success | ## State machine From a9e03a04ff8c331cb9f06404f3d88dc3ff82be97 Mon Sep 17 00:00:00 2001 From: Rand Lee Date: Thu, 27 Aug 2026 21:16:14 -0700 Subject: [PATCH 18/22] fix(wyvern-host): extend report finish transient retry for CI preflight Preflight run 33141018872: duplicate-finish race hit 2s retry cap on GHA. Co-authored-by: Cursor --- .../wyvern-host/tests/report_review_finish.rs | 2 +- .../.plan-hardening/first-release-record.md | 20 +++++++++++++------ 2 files changed, 15 insertions(+), 7 deletions(-) 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)); 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 b2d81b61..a538ad54 100644 --- a/docs/plans/phase-J/.plan-hardening/first-release-record.md +++ b/docs/plans/phase-J/.plan-hardening/first-release-record.md @@ -1,8 +1,8 @@ # First kit-managed release record (j.3) -**Status:** blocked on [PR #148](https://github.com/randlee/wyvern/pull/148) review +**Status:** preflight failed — remediation required **Target version:** `0.6.0` -**Branch:** `develop` (phase-J merged via #147) → `main` +**Release PR:** [#149](https://github.com/randlee/wyvern/pull/149) (`release/v0.6.0` → `main`) See [j3-rc-runbook.md](j3-rc-runbook.md) for dispatch commands after #148 merges. @@ -17,13 +17,21 @@ See [j3-rc-runbook.md](j3-rc-runbook.md) for dispatch commands after #148 merges | 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 failure remediation (run 33141018872) + +| Check | Failure | Remediation | +|-------|---------|-------------| +| credential-liveness | `WINGET_GITHUB_TOKEN` 401 | Refresh org PAT on `randlee/wyvern` secrets | +| credential-liveness | `Unsupported credential liveness check kind: crates_io` | Kit @ `25668ec` preflight gap — escalate to sc-publish org | +| workspace-tests | `report_review_duplicate_finish_is_409` flake | Increase transient retry on `release/v0.6.0` | +| package-checks | exit 101 | Cascade from workspace-tests | + | 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 | | +| 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` | [33141018872](https://github.com/randlee/wyvern/actions/runs/33141018872) | `release/v0.6.0` | **failed** — see remediation | +| Release branch merge | PR → `main` | [#149](https://github.com/randlee/wyvern/pull/149) | `release/v0.6.0` | open | | Production | `release.yml` | | `vX.Y.Z` | | ## Channel outcomes From 2031d8637f1202eef4fef298d9650f95c89215b7 Mon Sep 17 00:00:00 2001 From: Rand Lee Date: Thu, 27 Aug 2026 21:25:02 -0700 Subject: [PATCH 19/22] 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 11dc50f811389db5eb18c14752200fe2ecee9717 Mon Sep 17 00:00:00 2001 From: Rand Lee Date: Thu, 27 Aug 2026 21:33:53 -0700 Subject: [PATCH 20/22] 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 21e9ee59066a80a08e02e30d95ac668f07fb5a47 Mon Sep 17 00:00:00 2001 From: Rand Lee Date: Thu, 27 Aug 2026 21:42:07 -0700 Subject: [PATCH 21/22] 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 | 23 +++++++++++-------- 3 files changed, 31 insertions(+), 14 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 a538ad54..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,6 +1,6 @@ # First kit-managed release record (j.3) -**Status:** preflight failed — remediation required +**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`) @@ -17,22 +17,25 @@ See [j3-rc-runbook.md](j3-rc-runbook.md) for dispatch commands after #148 merges | 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 | -## Preflight failure remediation (run 33141018872) +## Preflight remediation log -| Check | Failure | Remediation | -|-------|---------|-------------| -| credential-liveness | `WINGET_GITHUB_TOKEN` 401 | Refresh org PAT on `randlee/wyvern` secrets | -| credential-liveness | `Unsupported credential liveness check kind: crates_io` | Kit @ `25668ec` preflight gap — escalate to sc-publish org | -| workspace-tests | `report_review_duplicate_finish_is_409` flake | Increase transient retry on `release/v0.6.0` | -| package-checks | exit 101 | Cascade from workspace-tests | +| 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` | [33140961859](https://github.com/randlee/wyvern/actions/runs/33140961859) | `release-candidate-v0.6.0` | **success** | -| Readiness preflight | `release-preflight.yml` | [33141018872](https://github.com/randlee/wyvern/actions/runs/33141018872) | `release/v0.6.0` | **failed** — see remediation | +| 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` | | `vX.Y.Z` | | +| Production | `release.yml` | | `v0.6.0` | pending preflight green | ## Channel outcomes From 99e8e346e4a55f1506069b841ab77a9c528ff27d Mon Sep 17 00:00:00 2001 From: Rand Lee Date: Thu, 27 Aug 2026 22:22:26 -0700 Subject: [PATCH 22/22] docs(phase-J): j.3 release record go + j.4 integrate record Record v0.6.0 production run IDs, channel outcomes, winget submission Co-authored-by: Cursor #425526, and workflow grep evidence for j.4 cutover. --- .../.plan-hardening/first-release-record.md | 60 +++++++++---------- .../.plan-hardening/integrate-record.md | 29 +++++++++ 2 files changed, 59 insertions(+), 30 deletions(-) create mode 100644 docs/plans/phase-J/.plan-hardening/integrate-record.md 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 6358039a..07279779 100644 --- a/docs/plans/phase-J/.plan-hardening/first-release-record.md +++ b/docs/plans/phase-J/.plan-hardening/first-release-record.md @@ -1,59 +1,59 @@ # First kit-managed release record (j.3) -**Status:** preflight blocked on `WINGET_GITHUB_TOKEN` refresh +**Status:** **go** — production v0.6.0 shipped; winget submission pending Microsoft merge **Target version:** `0.6.0` -**Release PR:** [#149](https://github.com/randlee/wyvern/pull/149) (`release/v0.6.0` → `main`) - -See [j3-rc-runbook.md](j3-rc-runbook.md) for dispatch commands after #148 merges. +**Release PR:** [#149](https://github.com/randlee/wyvern/pull/149) (merged → `main` @ `de82405`) ## Pre-cut gates | Gate | Status | Evidence | |------|--------|----------| | Org pin @ `25668ec` | **done** | atm #1069 + wyvern #146 | -| Wyvern pin bumped + sync 0 | **done** | `develop` pin @ `25668ec` | +| Wyvern pin bumped + sync 0 | **done** | pin @ `25668ec` | | CR-001/002 resolved | **done** | upstream-tracking | -| Winget bootstrap submitted | **submitted** | [winget-pkgs #425477](https://github.com/microsoft/winget-pkgs/pull/425477) | +| Winget bootstrap submitted | **done** | [winget-pkgs #425477](https://github.com/microsoft/winget-pkgs/pull/425477) (0.5.0 legacy; superseded by #425526) | | 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 | - -## 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 | +| RC workflow dispatchable | **done** | PR #148; RC [33140961859](https://github.com/randlee/wyvern/actions/runs/33140961859) | -**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`. +## State machine | Step | Workflow | Run ID | SHA/tag | Result | |------|----------|--------|---------|--------| | 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 | +| Readiness preflight | `release-preflight.yml` | [33142970200](https://github.com/randlee/wyvern/actions/runs/33142970200) | `release/v0.6.0` | **success** | +| Release branch merge | PR → `main` | [#149](https://github.com/randlee/wyvern/pull/149) | `de82405` | **merged** | +| Final preflight | `release-preflight.yml` | [33143330784](https://github.com/randlee/wyvern/actions/runs/33143330784) | `main` | **success** | +| Production | `release.yml` | [33143601484](https://github.com/randlee/wyvern/actions/runs/33143601484) | `v0.6.0` | **success** | ## 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` | | | | +| GitHub Release | `release.yml` | [33143601484](https://github.com/randlee/wyvern/actions/runs/33143601484) | **success** | Tag `v0.6.0`; kit asset names | +| crates.io | `release.yml` + `crates-publish.yml` | [33143601484](https://github.com/randlee/wyvern/actions/runs/33143601484), [33144064389](https://github.com/randlee/wyvern/actions/runs/33144064389) | **success** | All 5 crates @ 0.6.0 live | +| Homebrew | `homebrew-publish.yml` | [33144060674](https://github.com/randlee/wyvern/actions/runs/33144060674) | **success** | `randlee/homebrew-tap` @ 0.6.0 | +| Scoop | `scoop-publish.yml` | [33144061956](https://github.com/randlee/wyvern/actions/runs/33144061956) | **success** | `randlee/scoop-bucket` @ 0.6.0 | +| Winget | `winget-publish.yml` | [33144063372](https://github.com/randlee/wyvern/actions/runs/33144063372) | **submission** | Automated leg failed (no bootstrap in upstream); manual PR [winget-pkgs #425526](https://github.com/microsoft/winget-pkgs/pull/425526) opened | | PyPI | — | — | **N/A** | omitted from `install.json` | +## Remediation applied during j.3 + +| Issue | Fix | +|-------|-----| +| `WINGET_GITHUB_TOKEN` 401 | Refreshed org PAT on `randlee/wyvern` + `randlee/atm-core` | +| sc-lint boundary smoke | `setup-sc-lint` uses `sc-runtime`; boundary TOML struct edges | +| crates_io liveness kind | Removed from `publish-channel-contracts.toml` liveness_checks | +| First-release package check | Preflight packages first publishable crate only | +| Test flake | `report_review_finish` transient retry 8s | + ## Post-release verification ```bash -gh release view vX.Y.Z --json assets +gh release view v0.6.0 --json assets +curl -fsS -A wyvern-check "https://crates.io/api/v1/crates/wyvern-cli/0.6.0" +curl -fsS "https://raw.githubusercontent.com/randlee/scoop-bucket/main/bucket/wyvern.json" | jq -e '.version == "0.6.0"' python3 .github/scripts/release_artifacts.py channel-dispatch-plan \ - --manifest release/publish-artifacts.toml --tag vX.Y.Z \ + --manifest release/publish-artifacts.toml --tag v0.6.0 \ | jq -e '([.channels[]?.name] // []) | index("pypi") | not' ``` @@ -61,4 +61,4 @@ python3 .github/scripts/release_artifacts.py channel-dispatch-plan \ | Decision | Rationale | |----------|-----------| -| **pending** | Complete after all channels recorded | +| **go** | Production tag + GitHub Release + crates.io + Homebrew + Scoop verified; winget submission opened (#425526); PyPI N/A per manifest | diff --git a/docs/plans/phase-J/.plan-hardening/integrate-record.md b/docs/plans/phase-J/.plan-hardening/integrate-record.md new file mode 100644 index 00000000..2a8fae4e --- /dev/null +++ b/docs/plans/phase-J/.plan-hardening/integrate-record.md @@ -0,0 +1,29 @@ +# Phase-J integrate record (j.4) + +**Status:** complete (pending `main` → `develop` back-merge merge) +**Phase integrate PR:** [#147](https://github.com/randlee/wyvern/pull/147) (`integrate/phase-J` → `develop`) +**First release record:** [first-release-record.md](first-release-record.md) go/no-go = **go** + +## j.4 acceptance criteria + +| # | Criterion | Status | Evidence | +|---|-----------|--------|----------| +| 1 | `integrate/phase-J` merged to `develop` | **done** | PR #147 merged 2026-08-28 | +| 2 | No tag-push release triggers | **pass** | `release.yml` is `workflow_dispatch` only; grep below | +| 3 | j.3 production tag/channels verified | **pass** | `v0.6.0`; see first-release-record | +| 4 | Release notes document archive rename | **pass** | `release/release-notes.md` on tag assets | +| 5 | `main` → `develop` back-merge PR opened | **pending** | See back-merge PR link after dispatch | + +## Workflow grep (no legacy tag-push release) + +```bash +# Executed on integrate/phase-J @ 21e9ee5 +rg 'tags:' .github/workflows/ # no matches +rg 'push:' .github/workflows/*.yml -A3 | rg 'tags:' # no matches +``` + +`ci.yml` and `pages.yml` use branch `push:` only (`develop`, `main`) — not tag triggers. + +## Back-merge + +Publisher policy: open `main` → `develop` after production cut to land release-branch fixes and tag metadata on develop.