diff --git a/.github/settings/actions-selected.json b/.github/settings/actions-selected.json index d8d64d0..2357af7 100644 --- a/.github/settings/actions-selected.json +++ b/.github/settings/actions-selected.json @@ -5,6 +5,7 @@ "github/codeql-action@*", "actions-rust-lang/setup-rust-toolchain@*", "astral-sh/setup-uv@*", + "rust-lang/crates-io-auth-action@c6f97d42243bad5fab37ca0427f495c86d5b1a18", "codecov/codecov-action@*", "zizmorcore/zizmor-action@*", "taiki-e/cache-cargo-install-action@*", diff --git a/.github/settings/crates-io-environment.json b/.github/settings/crates-io-environment.json new file mode 100644 index 0000000..2a2e7c8 --- /dev/null +++ b/.github/settings/crates-io-environment.json @@ -0,0 +1,15 @@ +{ + "wait_timer": 0, + "prevent_self_review": false, + "reviewers": [ + { + "type": "User", + "id": 434849 + } + ], + "can_admins_bypass": false, + "deployment_branch_policy": { + "protected_branches": false, + "custom_branch_policies": true + } +} diff --git a/.github/settings/crates-io-tags.json b/.github/settings/crates-io-tags.json new file mode 100644 index 0000000..8e8e8e9 --- /dev/null +++ b/.github/settings/crates-io-tags.json @@ -0,0 +1,4 @@ +{ + "name": "v*", + "type": "tag" +} diff --git a/.github/workflows/publish-crates.yml b/.github/workflows/publish-crates.yml new file mode 100644 index 0000000..75a0b28 --- /dev/null +++ b/.github/workflows/publish-crates.yml @@ -0,0 +1,97 @@ +name: Publish to crates.io + +on: + release: + types: [published] + +permissions: + contents: read + +# Serialize all uploads for this crate, including reruns. Never cancel an upload. +concurrency: + group: publish-crates-la-stack + cancel-in-progress: false + +env: + CARGO_TERM_COLOR: always + RUST_BACKTRACE: 1 + +jobs: + verify: + if: >- + github.repository == 'acgetchell/la-stack' && + github.event_name == 'release' && github.event.action == 'published' && + !github.event.release.draft && !github.event.release.prerelease + runs-on: ubuntu-latest + timeout-minutes: 45 + permissions: + contents: read + checks: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.sha }} + fetch-depth: 0 + persist-credentials: false + + - name: Set up declared tools without caches + uses: ./.github/actions/setup-tools # zizmor: ignore[self-repository] actionlint 1.7.12 does not accept $/... + with: + cache: "false" + + - name: Require reviewed release evidence + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ github.event.release.tag_name }} + run: just release-gate "$RELEASE_TAG" + + - name: Verify Cargo packaging without uploading + run: just release-package + + publish: + needs: verify + runs-on: ubuntu-latest + timeout-minutes: 45 + environment: crates-io + permissions: + contents: read + checks: read + id-token: write + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.sha }} + fetch-depth: 0 + persist-credentials: false + + - name: Set up declared tools without caches + uses: ./.github/actions/setup-tools # zizmor: ignore[self-repository] actionlint 1.7.12 does not accept $/... + with: + cache: "false" + + - name: Recheck evidence and registry absence after deployment approval + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ github.event.release.tag_name }} + run: | + set -euo pipefail + just release-gate "$RELEASE_TAG" + uv run --locked --no-sync research-repo-tools release registry "$RELEASE_TAG" --expect absent + + - name: Obtain a short-lived crates.io token + id: auth + uses: rust-lang/crates-io-auth-action@c6f97d42243bad5fab37ca0427f495c86d5b1a18 # v1.0.5 + + # The verify job built default/exact packages without upload credentials. + # Repackage the same clean, revalidated commit without running build scripts. + - name: Package and upload verified source without building + env: + CARGO_REGISTRY_TOKEN: ${{ steps.auth.outputs.token }} + run: uv run --locked --no-sync research-repo-tools toolchain run -- cargo publish --locked --package la-stack --registry crates-io --no-verify + + - name: Check visibility even after a Cargo upload timeout + if: ${{ !cancelled() && steps.auth.outcome == 'success' }} + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ github.event.release.tag_name }} + run: just release-verify "$RELEASE_TAG" diff --git a/.github/workflows/release-benchmarks.yml b/.github/workflows/release-benchmarks.yml index 5f8a4de..8be4182 100644 --- a/.github/workflows/release-benchmarks.yml +++ b/.github/workflows/release-benchmarks.yml @@ -1,6 +1,6 @@ name: Release Benchmarks -# Attach full Criterion baselines to a draft before publishing the release. +# Attach full Criterion baselines for maintainer review before publication. # Release jobs publish durable artifacts, so they intentionally do not restore # or save dependency caches. @@ -182,7 +182,7 @@ jobs: with: name: ${{ needs.release-baseline.outputs.artifact-name }} - - name: Attach baseline and publish draft + - name: Attach baseline for review env: GH_TOKEN: ${{ github.token }} GH_REPO: ${{ github.repository }} @@ -196,7 +196,7 @@ jobs: printf '%s\n' "$RELEASE_TARGET" > "$RUNNER_TEMP/release-target.json" uvx --python 3.15 --from research-repo-tools==0.1.9 research-repo-tools \ --root "$RUNNER_TEMP" performance release-upload "$GH_REPO" "$RELEASE_TAG" \ - "$GITHUB_WORKSPACE/$RELEASE_ASSET" --target "$RUNNER_TEMP/release-target.json" --publish + "$GITHUB_WORKSPACE/$RELEASE_ASSET" --target "$RUNNER_TEMP/release-target.json" - name: Release baseline summary env: @@ -207,5 +207,6 @@ jobs: { echo "### Release Benchmark Baseline" echo "" - echo "Published release with verified asset: \`$RELEASE_ASSET\`" + echo "Draft release with verified asset: \`$RELEASE_ASSET\`" + echo "Review the draft, then run \`just release-publish \` as the maintainer." } >> "$GITHUB_STEP_SUMMARY" diff --git a/docs/BENCHMARKING.md b/docs/BENCHMARKING.md index 6dab22e..1e0a4b8 100644 --- a/docs/BENCHMARKING.md +++ b/docs/BENCHMARKING.md @@ -788,8 +788,9 @@ comparison is `docs/performance.md`, created by `just performance-release`. Follow [Releasing](RELEASING.md#5-create-the-draft-github-release): create the tagged stable release as a draft, dispatch the workflow with -`--ref "$TAG" -f tag="$TAG"`, and let the workflow upload and verify the archive before it -publishes the draft. Dispatch requires a stable `vX.Y.Z` tag and exactly one +`--ref "$TAG" -f tag="$TAG"`, and let the workflow upload and verify the archive. +Review the draft, then run `just release-publish "$TAG"` as the maintainer to +start the separate crates.io workflow. Dispatch requires a stable `vX.Y.Z` tag and exactly one mutable draft with that tag as its title. The producer checks out the resolved tag commit only when it matches the workflow's own commit. The dispatch ref must be that same tag, which keeps execution in the tag's cache scope; the @@ -846,7 +847,7 @@ measurements all stop publication. Only successful validation permits packaging the single `criterion/` archive, including the inventory manifest, and uploading the temporary Actions artifact. The separate publisher attaches that archive as `la-stack-$TAG-criterion-baseline.tar.gz` to the draft, verifies its uploaded -state, size, and SHA-256 digest, and only then publishes the release. +state, size, and SHA-256 digest, and leaves the draft ready for review. ### Validate The Release Workflow @@ -861,12 +862,12 @@ just doc-check The Python suite executes the workflow's shell with the pinned installed CLI and a simulated GitHub provider to prove captured-target handoff, commit-change -rejection and explicit publication. Generic provider failure matrices belong +rejection and preservation of the draft for review. Generic provider failure matrices belong upstream. Archive fixtures retain scientific coverage and exact raw bytes. -Every hosted dispatch now requires a real release draft and authorizes its -publication; there is no producer-only manual mode. Use the -[release sequence](RELEASING.md#6-run-benchmarks-and-publish-the-draft) for a +Every hosted dispatch requires a real release draft and attaches verified +evidence for review. Use the +[release sequence](RELEASING.md#6-run-benchmarks-and-review-the-draft) for a planned release. Record the successful run URL and both elapsed suite times when validating a budget change. The estimates above still require this representative hosted run; local tests and archive fixtures do not establish diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 5f7b905..4949a35 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -1,9 +1,10 @@ # Releasing la-stack Prepare each `vX.Y.Z` release in a dedicated PR. After that PR is merged, -create the annotated tag, publish to crates.io, and create a draft GitHub release. -You then start the `Release Benchmarks` workflow, which attaches the durable -baseline and **automatically publishes that same draft as the public release**. +create the annotated tag and a draft GitHub release. Start `Release Benchmarks` +to attach the durable baseline, then review and publish the draft with +`just release-publish`. The published release starts `Publish to crates.io`; +approve its `crates-io` deployment to permit the Cargo upload. The local benchmarks used to prepare the release PR do not require a draft. The draft is needed later as the upload destination for the benchmark archive @@ -157,6 +158,9 @@ cargo publish --locked --allow-dirty --dry-run `just ci` includes the lockfile, citation, documentation, test, and benchmark compile checks. +The preparation dry-run permits the uncommitted release artifacts. After review +and merge, `just release-package` verifies the clean release source with both +default and `exact` features and explicitly selects only `la-stack`. ### 7. Review, stage, and commit the release artifacts @@ -230,18 +234,32 @@ section instead of embedding it. git push origin "$TAG" ``` -### 4. Publish to crates.io +### 4. Verify the release commit ```bash -cargo publish --locked +just release-package ``` +Require successful native `CI` builds on Linux, macOS and Windows, plus Clippy +and repository-rule SARIF checks, for this exact commit before proceeding. +These builds include the declared Rust 1.99.0 baseline, default/exact doctests, +all-feature tests, interval and linear-form regressions, examples and benchmark +compilation. The publishing policy in `pyproject.toml` binds all five check names +to the GitHub Actions App. CodeRabbit and human review remain protected-PR +requirements; CodeRabbit does not run on the merged `main` commit. Missing, +pending, cancelled or failed native checks block publication. If a bot merge did +not trigger push CI, dispatch the existing CI/security workflows on `main` and +verify their `headSha` equals the release commit. + ### 5. Create the draft GitHub release Create an unpublished release where the workflow can attach its benchmark archive: ```bash -gh release create "$TAG" --verify-tag --draft --title "$TAG" --notes-from-tag +NOTES="$(mktemp)" +just release-notes "$TAG" > "$NOTES" +gh release create "$TAG" --verify-tag --draft --title "$TAG" --notes-file "$NOTES" +rm "$NOTES" ``` Keep the release title identical to the tag, including its leading `v`. @@ -249,10 +267,7 @@ Leave it as a stable draft: publishing now would make the release immutable before the benchmark archive can be attached. GitHub recommends this [draft-first sequence for immutable releases](https://docs.github.com/en/code-security/concepts/supply-chain-security/immutable-releases). -### 6. Run benchmarks and publish the draft - -**Starting this workflow also authorizes automatic publication.** You do not -need a separate command to convert the draft into a published release. +### 6. Run benchmarks and review the draft Start the workflow from the release tag, passing the same tag as its input: @@ -261,8 +276,7 @@ gh workflow run release-benchmarks.yml --ref "$TAG" -f tag="$TAG" ``` Once GitHub accepts the command, you can close the terminal and leave the -workflow running. It runs on GitHub's runners and needs no further input to -publish the release. Check its status later, when convenient, on the +workflow running. Check its status later on the [Release Benchmarks Actions page](https://github.com/acgetchell/la-stack/actions/workflows/release-benchmarks.yml). The workflow runs from the tagged version of its definition, so the tag must @@ -274,8 +288,7 @@ The workflow automatically: 1. Runs the full benchmark suites for the tagged commit. 2. Uploads the archive to the draft and verifies its name, state, size, and SHA-256 digest. -3. Removes the draft status, making the same release public with the archive - already attached. +3. Leaves the draft and verified archive ready for maintainer review. A benchmark, upload, or verification failure leaves the release as a draft. Follow [Recovering a failed run](#recovering-a-failed-run) to retry. @@ -285,7 +298,7 @@ exactly one mutable, non-prerelease draft whose tag and title match the stable `vX.Y.Z` input, and resolves the existing tag to a commit. It benchmarks that commit only if it matches the workflow's own commit, then rechecks the captured release ID and tag commit before attaching -the archive and again before publication. Do not edit the draft, move the tag, +the archive. Do not edit the draft, move the tag, or publish manually while the workflow is running. The privileged jobs install the exact published `research-repo-tools==0.1.9` @@ -293,34 +306,81 @@ package on Python 3.15 without repository checkout. Preflight emits versioned target JSON with the captured ID, commit and title assertion. Its trusted job output passes directly to the publisher, separately from the producer's inert archive. The shared writer verifies downloaded asset bytes as well as provider -metadata and receives explicit `--publish` intent. +metadata. It leaves publication to the authenticated maintainer. Runs for the same tag are serialized without cancelling an active run. -### 7. Verify publication and the durable Criterion baseline +### 7. Publish the reviewed draft and approve Cargo publication After the workflow completes, open the repository's -[Releases page](https://github.com/acgetchell/la-stack/releases). Confirm that -`$TAG` is published and its assets include -`la-stack-$TAG-criterion-baseline.tar.gz`. This is a check of the completed -publication; the workflow does not wait for you to perform it. +[Releases page](https://github.com/acgetchell/la-stack/releases). Review the +draft, generated notes and `la-stack-$TAG-criterion-baseline.tar.gz` asset. +From the clean tagged checkout, using your human GitHub CLI session, run: + +```bash +just release-publish "$TAG" +gh run list --workflow publish-crates.yml --limit 5 +``` + +This command authorizes GitHub Release publication. The shared gate checks the +clean checkout, stable tag/package metadata, remote tag commit, protected default +branch ancestry, successful exact-commit checks and required baseline asset. +The local recipe also requires the release title and body to match the tag and +generated notes. Human authentication is essential: publishing with a workflow's +`GITHUB_TOKEN` does not trigger the separate `release: published` workflow. + +The publishing workflow checks out `github.sha`, rechecks the published event's +repository, release ID, tag/ref/commit and evidence, then runs `just release-package`. +It also reads the captured release ID to require GitHub's immutable state. +After it succeeds, approve the `crates-io` environment deployment in GitHub. +The upload job rechecks the gate and requires proven absence of the exact registry +version after approval, then obtains a short-lived OIDC token. Only that job has +`id-token: write`; it has no repository write permission or dependency caches. +Uploads for `la-stack` are serialized without cancellation. + +The verification job builds the default and `exact` packages without upload +credentials. After approval, Cargo repackages the same clean, revalidated commit +with `--locked --no-verify`, so build scripts do not run with the crates.io token. +The dry-run archive is not transferred between jobs, and byte-identical archives +are not asserted. This follows [Cargo's native publication model](https://doc.rust-lang.org/cargo/commands/cargo-publish.html). +No benchmark workspace member is published. + +### 8. Verify registry availability and downstream resolution + +After the publishing workflow completes: -For an optional command-line check: +```bash +just release-verify "$TAG" +``` + +This requires the published stable GitHub release, nonempty durable baseline +asset and exact non-yanked crates.io version with a checksum. It retries only +proven registry absence, never an upload. A short-lived Actions artifact or a +published GitHub release alone is insufficient. + +Check a clean consumer with a fresh Cargo home before dependent Delaunay releases: ```bash -gh release view "$TAG" --json isDraft,assets \ - --jq "select(.isDraft == false) | .assets[] | select(.name == \"la-stack-$TAG-criterion-baseline.tar.gz\") | .name" | cat +CONSUMER="$(mktemp -d)" +CARGO_HOME="$(mktemp -d)"; export CARGO_HOME +RELEASE_CRATE="la-stack@=${TAG#v}" +cargo init --vcs none --name la_stack_release_check "$CONSUMER" +cargo add --manifest-path "$CONSUMER/Cargo.toml" "$RELEASE_CRATE" +cargo check --locked --manifest-path "$CONSUMER/Cargo.toml" +cargo add --manifest-path "$CONSUMER/Cargo.toml" "$RELEASE_CRATE" --features exact +cargo check --locked --manifest-path "$CONSUMER/Cargo.toml" ``` -The command must print `la-stack-$TAG-criterion-baseline.tar.gz`. A short-lived -Actions artifact is not a substitute for this release asset. +Use a disposable shell for this check. Preserve the output and record the tag, +commit, benchmark run, publishing run, registry URL/checksum and consumer results +in #259. Keep that issue open until the approved v0.5.0 publication is verified. The benchmark producer has read-only repository permissions and restores no dependency caches, including tool binaries. It invokes `setup-tools` with `cache: false`, installs declared managed Rust and Cargo tools through the locked shared package, and exports verified paths. The short preflight job receives `contents: write` -for draft visibility; the separate publisher receives it to attach the archive -and publish. Neither privileged job checks out or executes repository code. +for draft visibility; the separate asset writer receives it to attach the archive. +Neither privileged benchmark job checks out or executes repository code. The producer budgets 150 minutes for `vs_linalg` and 90 minutes for `exact` within a 285-minute job. Both suites retain full release sampling. Inventory @@ -359,7 +419,16 @@ without changing it, including when an earlier publication succeeded but its response was lost. Verify the public release in that case. Existing immutable releases with missing assets cannot be repaired by this workflow. -### 8. Remove the merged release branch +If the crates.io upload fails or Cargo times out, inspect the original run and +exact version before retrying. The workflow checks visibility even after an +upload failure but preserves the failed job result. A visible version makes a +rerun stop before OIDC/upload; use `just release-verify` and the clean consumer +check to finish verification. A registry transport/authentication error does not +prove absence. After a visibility timeout, investigate the accepted-upload state +before authorizing a rerun for the unchanged tag; never blindly retry an upload. +Corrections to published source require a new version. Never move a published tag. + +### 9. Remove the merged release branch After publication and baseline verification succeed: @@ -367,3 +436,44 @@ After publication and baseline verification succeed: git branch -d "release/$TAG" git push origin --delete "release/$TAG" ``` + +## Configure Trusted Publishing before v0.5.0 + +Follow the current [crates.io setup requirements](https://crates.io/docs/trusted-publishing). +Trusted Publishing requires an already-published crate and crate ownership; +initial publication uses an API token. `la-stack` already exists, so no throwaway +version or initial upload is needed. As its owner, register this exact identity +under the crate's Settings → Trusted Publishing: + +| Field | Value | +| --- | --- | +| Repository owner | `acgetchell` | +| Repository name | `la-stack` | +| Workflow filename | `publish-crates.yml` | +| Environment | `crates-io` | + +Create GitHub's `crates-io` environment with required maintainer reviewers, +administrator bypass disabled, and selected deployment tags matching `v*` only +(no branches). The proposed `.github/settings/crates-io-environment.json` names +`acgetchell` (user ID 434849) as the required reviewer and permits that maintainer +to approve their own deployment; self-review prevention needs another eligible +reviewer. `.github/settings/crates-io-tags.json` declares the tag restriction. +The stable-tag and reviewed-commit +gates still apply after deployment approval. Add only the pinned +`rust-lang/crates-io-auth-action` entry from `.github/settings/actions-selected.json` +to the live selected-action allowlist, preserving newer entries. This reviewed +v1.0.5 pin comes from the shared consumer template; update it through a reviewed +dependency PR and reconcile the allowlist together. The publishing workflow is +excluded from Dependabot automatic approval so these changes require maintainer +review. Do not add a long-lived +`CARGO_REGISTRY_TOKEN` secret. + +Workflow YAML and settings payloads do not apply account configuration. On +October 15, 2026, the GitHub environments API returned no environments and the +authenticated crates.io settings showed no trusted publishers. These payloads +and the publisher registration remain unapplied. Before release, record the actual crates.io +publisher identity and GitHub environment reviewers/tag policy, allowlist and +permissions evidence in #259. Re-read live settings before changing them. +Validate the source with `just lint-config`, `just python-ci`, `just markdown-ci` +and `just doc-check`; PR tests use simulated providers and never upload crates. +Native CI and successful approved publication remain separate rollout evidence. diff --git a/docs/code_organization.md b/docs/code_organization.md index 9ddad17..86e0e49 100644 --- a/docs/code_organization.md +++ b/docs/code_organization.md @@ -209,6 +209,14 @@ and hosted SARIF use it; ordinary support scripts use Python 3.15. `.github/actions/prepare-release-benchmarks/action.yml` groups tool installation, input validation, and inventory under the release workflow's shared setup timeout. +`.github/workflows/publish-crates.yml` owns the reviewed-release event, native +Cargo dry-run, deployment approval and OIDC upload. The publishing declaration +in `pyproject.toml` selects `la-stack`, native checks and the Criterion asset; +shared release gates own generic identity, ancestry and registry checks. +`scripts/tests/test_release_publishing.py` owns installed-package consumer checks +and upload-barrier tests. The [release guide](RELEASING.md) owns account setup, +manual publication, registry verification and recovery. + ## Documentation owners Use [Documentation guidance](dev/docs.md) for README, references, mathematical diff --git a/justfile b/justfile index d48c28b..e35ae21 100644 --- a/justfile +++ b/justfile @@ -44,6 +44,18 @@ _ensure-uv: _ensure-yamllint: _ensure-uv uv run --locked yamllint --version >/dev/null +# Require the GitHub release title and body to match the generated notes. +[private] +_release-notes-check tag: + #!/usr/bin/env bash + set -euo pipefail + tag={{ quote(tag) }} + notes="$(mktemp)" + trap 'rm -f "$notes"' EXIT + uv run --locked --no-sync research-repo-tools changelog notes "$tag" > "$notes" + gh release view "$tag" --repo acgetchell/la-stack --json tagName,name,body | + jq -e --arg tag "$tag" --rawfile notes "$notes" '.tagName == $tag and .name == $tag and ((.body | gsub("\r\n"; "\n") | rtrimstr("\n")) == ($notes | rtrimstr("\n")))' >/dev/null + # GitHub Actions workflow validation action-lint: _ensure-actionlint #!/usr/bin/env bash @@ -439,10 +451,29 @@ python-sync: _ensure-uv python-typecheck: python-sync uv run --locked ty check scripts/ tests/semgrep/scripts/ --error all +# Require the release event, reviewed commit, native checks, assets, and notes. +release-gate tag: + uv run --locked --no-sync research-repo-tools release gate {{ quote(tag) }} + gh api "repos/acgetchell/la-stack/releases/$(jq -er '.release.id' "$GITHUB_EVENT_PATH")" | jq -e '.immutable == true' >/dev/null + just _release-notes-check {{ quote(tag) }} + # Print release notes from the root changelog or a completed minor archive. release-notes tag: python-sync uv run --locked --group dev research-repo-tools changelog notes {{ quote(tag) }} +# Verify native Cargo packaging for the root crate, with default and exact features. +release-package: + {{ _run }} cargo publish --locked --package la-stack --registry crates-io --dry-run + {{ _run }} cargo publish --locked --package la-stack --registry crates-io --dry-run --features exact + +# Approve the benchmark-equipped draft as the authenticated maintainer. +release-publish tag: (_release-notes-check tag) + uv run --locked --no-sync research-repo-tools release publish {{ quote(tag) }} + +# Verify the published GitHub release, required assets, and exact crates.io version. +release-verify tag: + uv run --locked --no-sync research-repo-tools release verify {{ quote(tag) }} --attempts 7 --interval 10 + # Review branch and local changes against a verified origin/main, or an explicit local base. review base="origin/main": uv run --locked --group dev research-repo-tools review branch --base={{ quote(base) }} diff --git a/pyproject.toml b/pyproject.toml index f6b05ff..173908c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -134,6 +134,20 @@ dependency-bodies = "preserve" [tool.research-repo-tools.zizmor] persona = "regular" +[tool.research-repo-tools.publishing] +registry = "crates-io" +package = "la-stack" +repository = "acgetchell/la-stack" +# CodeRabbit review remains a protected-PR requirement; it does not run on main. +required-checks = [ + { name = "build (ubuntu-latest)", app-id = 15368 }, + { name = "build (macos-latest)", app-id = 15368 }, + { name = "build (windows-latest)", app-id = 15368 }, + { name = "Clippy SARIF Analysis", app-id = 15368 }, + { name = "Repository Rule SARIF Analysis", app-id = 15368 }, +] +required-assets = [ "la-stack-v{version}-criterion-baseline.tar.gz" ] + [tool.research-repo-tools.release] # Release preparation and post-merge tagging can happen on different UTC days. date-policy = "declared" diff --git a/scripts/README.md b/scripts/README.md index 45af1e0..b9fc74d 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -88,6 +88,16 @@ owns release metadata, changelog generation, tagging and registry publication. Repository declarations in `pyproject.toml` retain the concept DOI, active link policy, declared date policy, changelog formatter and dependency-body handling. +The publishing declaration selects only `la-stack`, requires the native platform +and scientific check results on the exact commit, and requires the durable +Criterion release asset. `release-gate`, `release-publish` and `release-verify` +use the shared release contract; `release-package` owns native default/exact +Cargo dry-runs. The local notes check binds the GitHub title/body to the generated +changelog. `scripts/tests/test_release_publishing.py` checks this consumer policy, +installed CLI failures and the actual workflow's upload barriers without network +access or registry credentials. Account registration and live release evidence +remain separate from fixture results. + The exact shared package pin lives in the `tooling` dependency group, included by `dev`, and is resolved from PyPI in `uv.lock`. No sibling checkout supplies runtime code. Shared `deps update-python` advances direct exact development diff --git a/scripts/tests/test_justfile_discoverability.py b/scripts/tests/test_justfile_discoverability.py index f29e1e3..8feaaf9 100644 --- a/scripts/tests/test_justfile_discoverability.py +++ b/scripts/tests/test_justfile_discoverability.py @@ -114,8 +114,8 @@ def test_bare_just_lists_the_complete_documented_surface_after_source_sorting() assert definitions == sorted(definitions) -def test_dependabot_shared_caller_covers_every_owned_dependency_file() -> None: - """New manifests or workflows cannot silently fall outside automatic eligibility.""" +def test_dependabot_shared_caller_covers_dependencies_with_explicit_manual_review() -> None: + """Every dependency file has an explicit automatic or manual review policy.""" workflow = yaml.safe_load((REPO_ROOT / ".github/workflows/dependabot-auto-merge.yml").read_bytes()) events = workflow.get("on", workflow.get(True)) assert set(events) == {"pull_request_target"} @@ -140,7 +140,10 @@ def test_dependabot_shared_caller_covers_every_owned_dependency_file() -> None: for path in directory.rglob("*") if path.suffix in {".yml", ".yaml"} } - assert set(policy["github_actions"]["files"]) == actions + # Publishing action pins must be reconciled with the live selected-actions policy. + manual_review = {".github/workflows/publish-crates.yml"} + assert manual_review <= actions + assert set(policy["github_actions"]["files"]) == actions - manual_review def test_zizmor_workflow_separates_findings_from_sarif_export() -> None: diff --git a/scripts/tests/test_release_baseline.py b/scripts/tests/test_release_baseline.py index 6b48b93..6bbbfb0 100644 --- a/scripts/tests/test_release_baseline.py +++ b/scripts/tests/test_release_baseline.py @@ -186,7 +186,8 @@ def test_workflow_requires_preflight_and_isolates_publication_permissions() -> N assert "target: ${{ steps.target.outputs.target }}" in preflight assert "RELEASE_TARGET: ${{ needs.validate-release.outputs.target }}" in publisher assert '--expected-title "$RELEASE_TAG"' in preflight - assert '--target "$RUNNER_TEMP/release-target.json" --publish' in publisher + assert '--target "$RUNNER_TEMP/release-target.json"' in publisher + assert "--publish" not in publisher assert (preflight + publisher).count("--from research-repo-tools==0.1.9") == 2 assert "uses: ./" not in preflight + publisher assert "--clobber" not in workflow @@ -300,7 +301,7 @@ def test_preflight_rejects_dispatch_outside_release_tag(tmp_path: Path, ref: str """ -def test_installed_cli_binds_trusted_target_and_explicit_publication(tmp_path: Path) -> None: +def test_installed_cli_binds_trusted_target_and_leaves_draft_for_review(tmp_path: Path) -> None: """Exercise actual preflight and writer shell with the pinned installed CLI.""" fixture = tmp_path / "provider with spaces" fixture.mkdir() @@ -340,13 +341,13 @@ def test_installed_cli_binds_trusted_target_and_explicit_publication(tmp_path: P assert json.loads(target)["release_id"] == 42 assert json.loads(target)["commit"] == "a" * 40 assert json.loads(target)["expected_title"] == BASELINE - stale = run_shell(runner + shell_step("Attach baseline and publish draft"), fixture, {**env, "RELEASE_TARGET": target, "REMOTE_COMMIT": "b" * 40}) + stale = run_shell(runner + shell_step("Attach baseline for review"), fixture, {**env, "RELEASE_TARGET": target, "REMOTE_COMMIT": "b" * 40}) assert stale.returncode != 0 assert not (fixture / "published").exists() - result = run_shell(runner + shell_step("Attach baseline and publish draft"), fixture, {**env, "RELEASE_TARGET": target}) + result = run_shell(runner + shell_step("Attach baseline for review"), fixture, {**env, "RELEASE_TARGET": target}) assert result.returncode == 0, result.stderr assert (fixture / "uploaded").exists() - assert (fixture / "published").exists() + assert not (fixture / "published").exists() calls = [json.loads(line) for line in (fixture / "api-calls").read_text(encoding="utf-8").splitlines()] - assert any("PATCH" in call for call in calls) + assert not any("PATCH" in call for call in calls) assert any("/releases/assets/7" in item for call in calls for item in call) diff --git a/scripts/tests/test_release_publishing.py b/scripts/tests/test_release_publishing.py new file mode 100644 index 0000000..e7d8a13 --- /dev/null +++ b/scripts/tests/test_release_publishing.py @@ -0,0 +1,226 @@ +"""Exercise la-stack's published release contract without credentials or uploads.""" + +import json +import os +import shlex +import subprocess +import sys +import tomllib +from pathlib import Path +from typing import Any + +import pytest +import yaml +from research_repo_tools import changelog, config, release_publishing +from research_repo_tools.cli import main +from research_repo_tools.just_inspect import dry_run +from research_repo_tools.process import run_command + +REPO_ROOT = Path(__file__).resolve().parents[2] +WORKFLOW = REPO_ROOT / ".github/workflows/publish-crates.yml" +SHA = "a" * 40 +POLICY = config.load(root=REPO_ROOT) +VERSION = tomllib.loads((REPO_ROOT / "Cargo.toml").read_text(encoding="utf-8"))["package"]["version"] +TAG = f"v{VERSION}" +CHECKS = ("build (ubuntu-latest)", "build (macos-latest)", "build (windows-latest)", "Clippy SARIF Analysis", "Repository Rule SARIF Analysis") + + +def test_publishing_policy_uses_the_published_pin_and_native_scientific_gates() -> None: + assert POLICY.publishing is not None + assert (POLICY.publishing.registry, POLICY.publishing.package, POLICY.publishing.repository) == ("crates-io", "la-stack", "acgetchell/la-stack") + assert tuple((check.name, check.app_id) for check in POLICY.publishing.required_checks) == tuple((name, 15368) for name in CHECKS) + assert POLICY.publishing.required_assets == ("la-stack-v{version}-criterion-baseline.tar.gz",) + manifest = tomllib.loads((REPO_ROOT / "pyproject.toml").read_text(encoding="utf-8")) + assert manifest["dependency-groups"]["tooling"] == ["research-repo-tools==0.1.9"] + lock = tomllib.loads((REPO_ROOT / "uv.lock").read_text(encoding="utf-8")) + package = next(item for item in lock["package"] if item["name"] == "research-repo-tools") + assert package["version"] == "0.1.9" + assert package["source"] == {"registry": "https://pypi.org/simple"} + + +@pytest.fixture +def release_event(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> dict[str, Any]: + """Supply only provider/read-only Git evidence to the installed release CLI.""" + event = { + "action": "published", + "repository": {"full_name": "acgetchell/la-stack"}, + "release": {"id": 42, "tag_name": TAG, "draft": False, "prerelease": False}, + } + event_file = tmp_path / "event.json" + event_file.write_text(json.dumps(event), encoding="utf-8") + for key, value in { + "GITHUB_EVENT_NAME": "release", + "GITHUB_REPOSITORY": "acgetchell/la-stack", + "GITHUB_SHA": SHA, + "GITHUB_REF": f"refs/tags/{TAG}", + "GITHUB_EVENT_PATH": str(event_file), + }.items(): + monkeypatch.setenv(key, value) + + def git(args: list[str], **_kwargs: object) -> subprocess.CompletedProcess[str]: + assert args in (["--no-pager", "rev-parse", "HEAD"], ["--no-pager", "status", "--porcelain", "--untracked-files=all"]) + return subprocess.CompletedProcess(args, 0, stdout=SHA if "rev-parse" in args else "", stderr="") + + checks = [ + {"id": index, "name": name, "head_sha": SHA, "app": {"id": 15368}, "status": "completed", "conclusion": "success"} + for index, name in enumerate(CHECKS, 1) + ] + responses: dict[str, object] = { + "": {"full_name": "acgetchell/la-stack", "default_branch": "main"}, + "branches/main": {"name": "main", "protected": True}, + f"compare/{SHA}...main": {"status": "identical"}, + f"commits/{SHA}/check-runs?filter=latest&per_page=100": [{"total_count": len(checks), "check_runs": checks}], + } + monkeypatch.setattr(release_publishing, "run_git_command", git) + monkeypatch.setattr(release_publishing, "_api", lambda _root, _repo, endpoint, **_kwargs: responses[endpoint]) + monkeypatch.setattr(release_publishing, "_remote_commit", lambda *_args: SHA) + release = release_publishing.GitHubRelease( + repository="acgetchell/la-stack", + tag=TAG, + identifier=42, + draft=False, + immutable=True, + prerelease=False, + assets=((f"la-stack-{TAG}-criterion-baseline.tar.gz", 7, 100, "digest"),), + ) + monkeypatch.setattr(release_publishing, "lookup_release", lambda *_args: release) + return {"event": event, "path": event_file, "checks": checks} + + +def test_installed_gate_accepts_actual_metadata_and_required_native_results(release_event: dict[str, Any]) -> None: + assert main(["--root", str(REPO_ROOT), "release", "gate", TAG]) == 0 + + +@pytest.mark.parametrize("name", CHECKS) +@pytest.mark.parametrize("state", ["missing", "failure", "pending"]) +def test_every_native_gate_blocks_missing_or_unsuccessful_evidence(release_event: dict[str, Any], name: str, state: str) -> None: + check = next(item for item in release_event["checks"] if item["name"] == name) + if state == "missing": + check["name"] = "unrelated check" + else: + check.update(status="in_progress" if state == "pending" else "completed", conclusion=None if state == "pending" else "failure") + assert main(["--root", str(REPO_ROOT), "release", "gate", TAG]) == 1 + + +@pytest.mark.parametrize(("field", "value"), [("draft", True), ("prerelease", True), ("tag_name", "v99.0.0")]) +def test_installed_gate_rejects_mismatched_release_events(release_event: dict[str, Any], field: str, value: object) -> None: + release_event["event"]["release"][field] = value + release_event["path"].write_text(json.dumps(release_event["event"]), encoding="utf-8") + assert main(["--root", str(REPO_ROOT), "release", "gate", TAG]) == 1 + + +@pytest.mark.parametrize("mismatch", ["repository", "commit", "version", "event"]) +def test_consumer_gate_rejects_wrong_identity(release_event: dict[str, Any], monkeypatch: pytest.MonkeyPatch, mismatch: str) -> None: + tag = TAG + if mismatch == "repository": + monkeypatch.setenv("GITHUB_REPOSITORY", "other/repository") + elif mismatch == "commit": + monkeypatch.setattr(release_publishing, "_remote_commit", lambda *_args: "b" * 40) + elif mismatch == "version": + tag = "v99.0.0" + else: + monkeypatch.setenv("GITHUB_EVENT_NAME", "pull_request") + assert main(["--root", str(REPO_ROOT), "release", "gate", tag]) == 1 + + +@pytest.mark.parametrize("immutable", [True, False, None]) +def test_recipe_requires_immutable_publication(tmp_path: Path, immutable: bool | None) -> None: + payload = tmp_path / "event.json" + payload.write_text(json.dumps({"release": {"id": 42}}), encoding="utf-8") + # The shared gate has its own installed-CLI tests above. Isolate this local + # immutable-release policy while running the actual Just recipe expansion. + script = dry_run(REPO_ROOT, "release-gate", [TAG]).stderr + provider = ( + f'gh() {{ [[ "$1 $2" == "api repos/acgetchell/la-stack/releases/42" ]] || return 90; echo {shlex.quote(json.dumps({"immutable": immutable}))}; }}\n' + ) + result = run_command( + "bash", + ["--noprofile", "--norc", "-euc", "set -o pipefail\nuv() { return 0; }\njust() { return 0; }\n" + provider + script], + env={ + **{key: value for key, value in os.environ.items() if key not in {"BASH_ENV", "SHELLOPTS", "BASHOPTS"}}, + "GITHUB_EVENT_PATH": payload.as_posix(), + }, + check=False, + ) + assert (result.returncode == 0) == (immutable is True) + + +@pytest.mark.parametrize("mismatch", ["none", "title", "notes"]) +def test_real_notes_recipe_checks_generated_body_and_title(tmp_path: Path, mismatch: str) -> None: + body = changelog.notes(POLICY, TAG)[0] + response = {"tagName": TAG, "name": "wrong" if mismatch == "title" else TAG, "body": "wrong" if mismatch == "notes" else body} + payload = tmp_path / "release.json" + payload.write_text(json.dumps(response), encoding="utf-8") + # Only acquisition/provider boundaries are substituted; execute the real CLI + # and recipe's jq comparison, preserving safe transport of Markdown bytes. + runner = ( + 'uv() { [[ "$1 $2 $3 $4" == "run --locked --no-sync research-repo-tools" ]] || return 90; shift 4; ' + f'{shlex.quote(sys.executable)} -m research_repo_tools --root {shlex.quote(str(REPO_ROOT))} "$@"; }}\n' + f"gh() {{ cat {shlex.quote(payload.as_posix())}; }}\n" + ) + script = dry_run(REPO_ROOT, "_release-notes-check", [TAG]).stderr + result = run_command("bash", ["--noprofile", "--norc", "-euc", runner + script], cwd=REPO_ROOT, check=False) + assert (result.returncode == 0) == (mismatch == "none"), result.stderr + + +def test_workflow_requires_approval_revalidation_and_absence_before_oidc() -> None: + workflow = yaml.safe_load(WORKFLOW.read_bytes()) + # PyYAML's YAML 1.1 parser reads the Actions `on` key as True. + assert workflow[True] == {"release": {"types": ["published"]}} + assert workflow["concurrency"] == {"group": "publish-crates-la-stack", "cancel-in-progress": False} + jobs = workflow["jobs"] + assert jobs["publish"]["needs"] == "verify" + assert jobs["publish"]["environment"] == "crates-io" + assert jobs["verify"]["permissions"] == {"contents": "read", "checks": "read"} + assert jobs["publish"]["permissions"] == {"contents": "read", "checks": "read", "id-token": "write"} + assert "github.repository == 'acgetchell/la-stack'" in jobs["verify"]["if"] + assert jobs["verify"]["steps"][-1]["run"] == "just release-package" + assert "CARGO_REGISTRY_TOKEN" not in yaml.safe_dump({"env": workflow["env"], "verify": jobs["verify"]}) + for job in jobs.values(): + checkout = job["steps"][0] + assert checkout["with"] == {"ref": "${{ github.sha }}", "fetch-depth": 0, "persist-credentials": False} + assert job["steps"][1]["with"]["cache"] == "false" + steps = jobs["publish"]["steps"] + gate = steps[2]["run"] + assert 'just release-gate "$RELEASE_TAG"' in gate + assert 'release registry "$RELEASE_TAG" --expect absent' in gate + assert steps[3]["id"] == "auth" + assert steps[4]["run"].endswith("cargo publish --locked --package la-stack --registry crates-io --no-verify") + assert [index for index, step in enumerate(steps) if "CARGO_REGISTRY_TOKEN" in step.get("env", {})] == [4] + assert steps[5]["if"] == "${{ !cancelled() && steps.auth.outcome == 'success' }}" + assert steps[5]["run"] == 'just release-verify "$RELEASE_TAG"' + assert "continue-on-error" not in WORKFLOW.read_text(encoding="utf-8") + commands = dry_run(REPO_ROOT, "release-package").stderr + assert commands.count("--package la-stack --registry crates-io --dry-run") == 2 + assert "--features exact" in commands + assert "--allow-dirty" not in commands + assert "--no-verify" not in commands + + +@pytest.mark.parametrize(("failure", "expected_exit"), [("none", 0), ("evidence", 1), ("present", 1), ("unavailable", 2)]) +def test_actual_preupload_shell_requires_evidence_and_proven_absence(tmp_path: Path, failure: str, expected_exit: int) -> None: + workflow = yaml.safe_load(WORKFLOW.read_bytes()) + script = workflow["jobs"]["publish"]["steps"][2]["run"] + marker = tmp_path / "upload-reached" + gate = "just() { return 1; }" if failure == "evidence" else "just() { return 0; }" + registry_exit = expected_exit if failure in {"present", "unavailable"} else 0 + registry = f"uv() {{ return {registry_exit}; }}" + script = f"{gate}\n{registry}\n{script}\ntouch {shlex.quote(marker.as_posix())}" + result = run_command( + "bash", + ["--noprofile", "--norc", "-euc", script], + env={**{key: value for key, value in os.environ.items() if key not in {"BASH_ENV", "SHELLOPTS", "BASHOPTS"}}, "RELEASE_TAG": TAG}, + check=False, + ) + assert result.returncode == expected_exit, result.stderr + assert marker.exists() == (failure == "none") + + +def test_native_packaging_failure_stops_before_further_validation(tmp_path: Path) -> None: + script = dry_run(REPO_ROOT, "release-package").stderr + calls = tmp_path / "calls" + # Cargo/dependency failure is propagated by the real recipe's strict shell. + runner = f"uv() {{ echo invoked >> {shlex.quote(calls.as_posix())}; return 17; }}\n" + result = run_command("bash", ["--noprofile", "--norc", "-euc", runner + script], check=False) + assert result.returncode == 17 + assert calls.read_text(encoding="utf-8").splitlines() == ["invoked"] diff --git a/semgrep.yaml b/semgrep.yaml index 4014927..1873485 100644 --- a/semgrep.yaml +++ b/semgrep.yaml @@ -413,7 +413,7 @@ rules: - "/tests/semgrep/.github/workflows/**/*.yml" - "/tests/semgrep/.github/workflows/**/*.yaml" patterns: - - pattern-regex: '(?m)^[ \t]*(?:-[ \t]*)?uses:\s*(?!\./)(?!docker://)(?!(?:actions/checkout|actions/cache|actions/download-artifact|actions/github-script|actions/setup-python|actions/upload-artifact|actions-rust-lang/setup-rust-toolchain|astral-sh/setup-uv|codecov/codecov-action|github/codeql-action/(?:upload-sarif|init|analyze)|swatinem/rust-cache|taiki-e/cache-cargo-install-action|zizmorcore/zizmor-action|acgetchell/research-repo-tools/\.github/workflows/dependabot-approve\.yml)@)[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+(?:/[A-Za-z0-9_.-]+)*@' # yamllint disable-line rule:line-length + - pattern-regex: '(?m)^[ \t]*(?:-[ \t]*)?uses:\s*(?!\./)(?!docker://)(?!(?:actions/checkout|actions/cache|actions/download-artifact|actions/github-script|actions/setup-python|actions/upload-artifact|actions-rust-lang/setup-rust-toolchain|astral-sh/setup-uv|codecov/codecov-action|github/codeql-action/(?:upload-sarif|init|analyze)|rust-lang/crates-io-auth-action|swatinem/rust-cache|taiki-e/cache-cargo-install-action|zizmorcore/zizmor-action|acgetchell/research-repo-tools/\.github/workflows/dependabot-approve\.yml)@)[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+(?:/[A-Za-z0-9_.-]+)*@' # yamllint disable-line rule:line-length - id: la-stack.github-actions.external-action-version-comment languages: diff --git a/tests/semgrep/.github/workflows/action_policy.yml b/tests/semgrep/.github/workflows/action_policy.yml index 6b82528..8621a45 100644 --- a/tests/semgrep/.github/workflows/action_policy.yml +++ b/tests/semgrep/.github/workflows/action_policy.yml @@ -20,6 +20,10 @@ jobs: action-shorthand: runs-on: ubuntu-latest steps: + # ok: la-stack.github-actions.external-action-approved-allowlist, la-stack.github-actions.external-action-sha-pinned + - uses: rust-lang/crates-io-auth-action@c6f97d42243bad5fab37ca0427f495c86d5b1a18 # v1.0.5 + # ruleid: la-stack.github-actions.external-action-approved-allowlist + - uses: unapproved/crates-io-auth-action@c6f97d42243bad5fab37ca0427f495c86d5b1a18 # v1.0.5 # ok: la-stack.github-actions.external-action-approved-allowlist, la-stack.github-actions.external-action-sha-pinned - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 # ruleid: la-stack.github-actions.external-action-sha-pinned