From 4f577007e9753f3b0624a48525e70f05f21717be Mon Sep 17 00:00:00 2001 From: Adam Getchell Date: Sun, 8 Nov 2026 12:30:53 -0800 Subject: [PATCH 1/4] feat: automate release evidence reuse and parallelize CI - Discover the original acquisition owner and authenticated tag-push draft receipt automatically while retaining explicit recovery inputs. - Align release commands and documentation around pre-tag evidence review, automatic draft preparation, and retained-evidence publication dispatch. - Require an existing reviewed acceptance before tagging, staging, or publishing, and preserve exact release and artifact identities on retries. - Overlap independent Rust and Python validation after policy checks, retain a serial fallback, and cache managed Cargo compilation separately from verified tool installations. - Adopt research-repo-tools at the authorized cd8594f development revision. Refs #330 Refs #338 --- .github/actions/setup-tools/action.yml | 13 +- .github/workflows/ci.yml | 4 +- .github/workflows/prepare-release.yml | 191 +++++++++ .github/workflows/release-benchmarks.yml | 6 +- CONTRIBUTING.md | 19 +- docs/BENCHMARKING.md | 5 +- docs/RELEASING.md | 260 ++++++++++--- docs/code_organization.md | 13 + docs/dev/release-flow-integration.md | 134 +++++++ justfile | 86 +++-- pyproject.toml | 2 +- tests/tooling/test_candidate_transfer.py | 48 ++- .../tooling/test_justfile_discoverability.py | 4 +- tests/tooling/test_performance_workflow.py | 17 +- tests/tooling/test_release_context.py | 363 ++++++++++++++++++ tests/tooling/test_release_flow.py | 241 ++++++++++++ tests/tooling/test_release_publishing.py | 10 +- tests/tooling/test_toolchain_integration.py | 2 +- tests/tooling/test_validation_graph.py | 132 +++++++ tooling/README.md | 29 +- tooling/draft-receipt.toml | 15 + tooling/release_candidate.py | 255 ++++++++++-- uv.lock | 6 +- 23 files changed, 1710 insertions(+), 145 deletions(-) create mode 100644 .github/workflows/prepare-release.yml create mode 100644 docs/dev/release-flow-integration.md create mode 100644 tests/tooling/test_release_context.py create mode 100644 tests/tooling/test_release_flow.py create mode 100644 tests/tooling/test_validation_graph.py create mode 100644 tooling/draft-receipt.toml diff --git a/.github/actions/setup-tools/action.yml b/.github/actions/setup-tools/action.yml index c54b5e6..5718a9c 100644 --- a/.github/actions/setup-tools/action.yml +++ b/.github/actions/setup-tools/action.yml @@ -3,7 +3,7 @@ description: Install the locked shared package, synchronize declared tools, and inputs: cache: - description: Restore and save managed tool installations; release jobs must disable this. + description: Cache dependencies and managed Cargo compilation; release jobs must disable this. required: false default: "true" @@ -17,18 +17,21 @@ runs: enable-cache: ${{ inputs.cache }} cache-python: false - - name: Cache declared Rust and Cargo tools + - name: Cache managed Cargo compilation if: inputs.cache == 'true' uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: - path: ${{ runner.temp }}/rrt - key: rrt-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('rust-toolchain.toml', 'pyproject.toml', 'uv.lock', '.python-version') }} - restore-keys: rrt-${{ runner.os }}-${{ runner.arch }}- + path: ${{ runner.temp }}/rrt-cargo-build + key: >- + cargo-build-v1-${{ runner.os }}-${{ runner.arch }}-${{ + hashFiles('rust-toolchain.toml', 'pyproject.toml', 'uv.lock', 'Cargo.lock', '.python-version') + }} - name: Set up declared tools and development environment shell: bash env: RESEARCH_REPO_TOOLS_HOME: ${{ runner.temp }}/rrt + RESEARCH_REPO_TOOLS_CARGO_BUILD_HOME: ${{ runner.temp }}/rrt-cargo-build run: uv run --locked --managed-python --only-group tooling research-repo-tools setup - name: Export verified managed paths diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6d98522..f455a45 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -66,7 +66,7 @@ jobs: with: cache: "false" - name: Verify default and exact Cargo packages - run: just release-package + run: just publish-check release-evidence: needs: [build, release-package] @@ -93,7 +93,7 @@ jobs: GH_TOKEN: ${{ github.token }} run: | uvx --python 3.15 \ - --from 'research-repo-tools @ git+https://github.com/acgetchell/research-repo-tools@055353c630604b1ae3438c1eaf534ba85e067a7e' \ + --from 'research-repo-tools @ git+https://github.com/acgetchell/research-repo-tools@cd8594f658b5bf3ade063a685834565cc62e0cb3' \ python -I - <<'PY' import os import sys diff --git a/.github/workflows/prepare-release.yml b/.github/workflows/prepare-release.yml new file mode 100644 index 0000000..576992d --- /dev/null +++ b/.github/workflows/prepare-release.yml @@ -0,0 +1,191 @@ +name: Prepare GitHub release + +on: + push: + tags: + - v* + +permissions: + contents: read + actions: read + +# All cooperative draft writers serialize by repository/tag without cancellation. +concurrency: + group: release-draft-${{ github.repository }}-${{ github.ref_name }} + cancel-in-progress: false + +jobs: + verify: + if: github.repository == 'acgetchell/la-stack' + runs-on: ubuntu-latest + timeout-minutes: 15 + outputs: + commit: ${{ steps.source.outputs.commit }} + artifact-id: ${{ steps.handoff.outputs.artifact-id }} + artifact-digest: ${{ steps.handoff.outputs.artifact-digest }} + validated-attempt: ${{ github.run_attempt }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.sha }} + fetch-depth: 0 + persist-credentials: false + - name: Install trusted package runner + uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2 + with: + version: "0.13.7" + enable-cache: false + cache-python: false + - name: Verify tagged source and freeze native evidence and notes + id: source + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ github.ref_name }} + shell: bash + run: | + set -euo pipefail + [[ "$RELEASE_TAG" =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]] + [[ "$GITHUB_EVENT_NAME" == push && "$GITHUB_REF" == "refs/tags/$RELEASE_TAG" ]] + commit=$(git --no-pager rev-parse "$GITHUB_REF^{commit}") + [[ "$commit" == "$GITHUB_SHA" && "$(git --no-pager rev-parse HEAD)" == "$commit" ]] + git --no-pager merge-base --is-ancestor "$commit" origin/main + mkdir "$RUNNER_TEMP/handoff" + cp tooling/release-evidence.toml "$RUNNER_TEMP/handoff/evidence.toml" + runtime=(uv run --no-config --no-project --isolated --no-cache --python 3.15 + --with 'research-repo-tools @ git+https://github.com/acgetchell/research-repo-tools@cd8594f658b5bf3ade063a685834565cc62e0cb3' + python -I -c 'import sys; from research_repo_tools.cli import main; sys.exit(main())') + "${runtime[@]}" --root "$GITHUB_WORKSPACE" release check "$RELEASE_TAG" + "${runtime[@]}" --root "$GITHUB_WORKSPACE" release evidence verify \ + "$RUNNER_TEMP/handoff/evidence.toml" --checkout "$GITHUB_WORKSPACE" \ + --commit "$commit" --version "${RELEASE_TAG#v}" \ + --output "$RUNNER_TEMP/handoff/selection.json" + "${runtime[@]}" --root "$GITHUB_WORKSPACE" changelog notes "$RELEASE_TAG" > "$RUNNER_TEMP/handoff/notes.md" + printf 'commit=%s\n' "$commit" >> "$GITHUB_OUTPUT" + - name: Retain inert verified handoff + id: handoff + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: draft-handoff-${{ github.run_id }}-${{ github.run_attempt }} + path: ${{ runner.temp }}/handoff/* + if-no-files-found: error + retention-days: 30 + + prepare: + needs: verify + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + actions: read + contents: write + steps: + # No checkout, local action, project environment or handoff code executes here. + - name: Install trusted package runner + uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2 + with: + version: "0.13.7" + enable-cache: false + cache-python: false + - name: Authenticate handoff and prepare the unpublished draft + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ github.ref_name }} + RELEASE_COMMIT: ${{ needs.verify.outputs.commit }} + HANDOFF_ID: ${{ needs.verify.outputs.artifact-id }} + HANDOFF_SHA256: ${{ needs.verify.outputs.artifact-digest }} + VALIDATED_ATTEMPT: ${{ needs.verify.outputs.validated-attempt }} + shell: bash + run: | + set -euo pipefail + # Recovery must retrieve the original journal, never start a new intent. + test "$GITHUB_RUN_ATTEMPT" = 1 + test "$VALIDATED_ATTEMPT" = "$GITHUB_RUN_ATTEMPT" + cd "$RUNNER_TEMP" + uv run --no-config --no-project --isolated --no-cache --python 3.15 \ + --with 'research-repo-tools @ git+https://github.com/acgetchell/research-repo-tools@cd8594f658b5bf3ade063a685834565cc62e0cb3' \ + python -I - <<'PY' + import os + from functools import partial + from pathlib import Path + from research_repo_tools.files import replace_many + from research_repo_tools.release_assets import serialize_release_target + from research_repo_tools.release_drafts import prepare_release_draft + from research_repo_tools.release_interfaces import load_evidence_policy + from research_repo_tools.release_validation import api, download_artifact, read_pages + + root = Path.cwd() + repository = os.environ["GITHUB_REPOSITORY"] + run = int(os.environ["GITHUB_RUN_ID"]) + attempt = int(os.environ["GITHUB_RUN_ATTEMPT"]) + if (repository != "acgetchell/la-stack" or attempt != 1 + or os.environ["VALIDATED_ATTEMPT"] != str(attempt) + or os.environ["GITHUB_EVENT_NAME"] != "push" + or os.environ["GITHUB_REF"] != "refs/tags/" + os.environ["RELEASE_TAG"] + or os.environ["GITHUB_SHA"] != os.environ["RELEASE_COMMIT"]): + raise ValueError("draft preparation requires the original exact tag push") + identifier = int(os.environ["HANDOFF_ID"]) + artifact = api(f"actions/artifacts/{identifier}", repository=repository) + producer = artifact.get("workflow_run", {}) + if not ( + type(artifact.get("id")) is int and artifact["id"] == identifier + and artifact.get("expired") is False + and artifact.get("name") == f"draft-handoff-{run}-{attempt}" + and artifact.get("digest") == "sha256:" + os.environ["HANDOFF_SHA256"] + and type(producer.get("id")) is int and producer["id"] == run + and producer.get("head_sha") == os.environ["GITHUB_SHA"] + ): + raise ValueError("handoff artifact/run/digest identity differs") + current = api(f"actions/runs/{run}", repository=repository) + workflow = current.get("workflow_id") + if (type(current.get("id")) is not int or current["id"] != run + or type(workflow) is not int or workflow <= 0 + or type(current.get("run_attempt")) is not int or current["run_attempt"] != attempt + or current.get("path") not in { + ".github/workflows/prepare-release.yml", + ".github/workflows/prepare-release.yml@" + os.environ["GITHUB_REF"], + ".github/workflows/prepare-release.yml@" + os.environ["RELEASE_TAG"], + } + or current.get("event") != "push" + or current.get("head_branch") != os.environ["RELEASE_TAG"] + or current.get("head_sha") != os.environ["GITHUB_SHA"]): + raise ValueError("handoff producer attempt or event changed") + # Unfiltered history avoids the filtered workflow-run result cap. + history = read_pages(partial(api, repository=repository), + f"actions/workflows/{workflow}/runs", "workflow_runs") + same_tag, seen = [], set() + for previous in history: + if (type(previous.get("id")) is not int or previous["id"] <= 0 + or type(previous.get("workflow_id")) is not int or previous["workflow_id"] != workflow + or not isinstance(previous.get("event"), str) + or not isinstance(previous.get("head_branch"), str)): + raise ValueError("workflow history identity is incomplete") + if previous["id"] in seen: + raise ValueError("workflow history contains duplicate runs") + seen.add(previous["id"]) + if previous["event"] == "push" and previous["head_branch"] == os.environ["RELEASE_TAG"]: + same_tag.append(previous["id"]) + if same_tag != [run]: + raise ValueError("tag has prior or unproven preparation intent; recover the original journal") + handoff = root / "handoff" + download_artifact(repository, artifact, handoff, ("evidence.toml", "selection.json", "notes.md")) + latest = api(f"actions/runs/{run}", repository=repository) + if any(type(latest.get(key)) is not type(current.get(key)) or latest.get(key) != current.get(key) + for key in ("id", "workflow_id", "run_attempt", "event", "head_branch", "head_sha", "path")): + raise ValueError("handoff producer changed during download") + target = prepare_release_draft( + root, repository, os.environ["RELEASE_TAG"], os.environ["RELEASE_COMMIT"], + policy=load_evidence_policy(handoff / "evidence.toml"), + expected=(handoff / "selection.json").read_bytes(), title=os.environ["RELEASE_TAG"], + notes=(handoff / "notes.md").read_bytes(), receipt=root / "draft-receipt.json", approve=True, + ) + replace_many({root / "target.json": serialize_release_target(target)}) + PY + - name: Retain journal even after an unconfirmed creation + if: always() + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: draft-receipt-${{ github.run_id }}-${{ github.run_attempt }} + path: | + ${{ runner.temp }}/draft-receipt.json + ${{ runner.temp }}/target.json + if-no-files-found: ignore + retention-days: 30 diff --git a/.github/workflows/release-benchmarks.yml b/.github/workflows/release-benchmarks.yml index 58c6984..0c6e622 100644 --- a/.github/workflows/release-benchmarks.yml +++ b/.github/workflows/release-benchmarks.yml @@ -17,7 +17,7 @@ on: type: string concurrency: - group: release-benchmarks-${{ inputs.tag }} + group: release-draft-${{ github.repository }}-${{ inputs.tag }} cancel-in-progress: false jobs: @@ -49,7 +49,7 @@ jobs: run: | set -euo pipefail uvx --python 3.15 \ - --from 'research-repo-tools @ git+https://github.com/acgetchell/research-repo-tools@055353c630604b1ae3438c1eaf534ba85e067a7e' \ + --from 'research-repo-tools @ git+https://github.com/acgetchell/research-repo-tools@cd8594f658b5bf3ade063a685834565cc62e0cb3' \ python -I - <<'PY' import hashlib, json, os from pathlib import Path @@ -193,7 +193,7 @@ jobs: run: | set -euo pipefail uvx --python 3.15 \ - --from 'research-repo-tools @ git+https://github.com/acgetchell/research-repo-tools@055353c630604b1ae3438c1eaf534ba85e067a7e' \ + --from 'research-repo-tools @ git+https://github.com/acgetchell/research-repo-tools@cd8594f658b5bf3ade063a685834565cc62e0cb3' \ python -I - <<'PY' import hashlib, json, os from pathlib import Path diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 344d89d..9733e9b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -292,13 +292,28 @@ in [Testing guidance](docs/dev/testing.md). README and guide doctest requirement live in [Documentation guidance](docs/dev/docs.md). Run `just ci` for core Rust, public behavior, or GitHub-equivalent validation. -It composes leaf validators directly and runs `clippy-all-targets` to match the -GitHub Clippy SARIF workflow. Unit and integration tests still run together once +It synchronizes the environment once, completes fast policy checks, then overlaps +Python consumer tests with one sequential Rust lane. Use `just --jobs 1 ci` for +serial execution on a constrained host. Keep benchmark acquisition separate from +validation so concurrent work does not affect measurements. +The Rust lane runs `clippy-all-targets` to match the GitHub Clippy SARIF workflow. +Unit and integration tests still run together once through the release-profile `test-rust-ci` bucket, and doctests remain separate because nextest does not execute them. The test, example, and benchmark buckets retain their execution or compile-contract roles because ordinary compilation does not execute Clippy lints. +Native CI retains the same `build (OS)` and `Run CI checks` identities. Ordinary +setup caches only managed Cargo compilation by OS, architecture and exact tool +declarations; exact-version tool installations are freshly verified. GitHub's +cache ref scopes separate PR entries from protected main. Release packaging and +publication keep caches disabled. The shared local build cache defaults to the +tooling home's `cargo-build//`; an absolute +`RESEARCH_REPO_TOOLS_CARGO_BUILD_HOME` selects separate compilation storage. +This cache does not replace project builds or validation. See the +[integration record](docs/dev/release-flow-integration.md) for graph ownership, +baseline timings and remaining native/published-package evidence. + `la-stack` intentionally has no notebook validation bucket: the repository has no notebooks or supported Python binding surface. Add notebook tooling only alongside an actual maintained notebook interface rather than speculatively. diff --git a/docs/BENCHMARKING.md b/docs/BENCHMARKING.md index ea318fe..7be73e1 100644 --- a/docs/BENCHMARKING.md +++ b/docs/BENCHMARKING.md @@ -923,9 +923,12 @@ just bench-save-last The durable baseline is derived from the accepted Step 1 local acquisition. The committed comparison is `docs/performance.md`. Follow [Releasing](RELEASING.md#step-2-publish-after-the-pr-is-merged) to verify the -final clean commit, create a draft, stage the independent records, and dispatch +final clean commit, wait for the tag-triggered draft, stage the independent records +against its authenticated captured target, and dispatch attachment and publication. Both dispatches use the exact tag and owner-generated selection JSON; downloaded files cannot authenticate their own assertions. +`just release-benchmarks "$TAG"` dispatches attachment using the explicitly +selected `CANDIDATE_TRANSFER_FILE` and returns without waiting or acquiring data. ### Hosted Release Verification diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 003e710..fbb0846 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -3,13 +3,14 @@ Prepare each `vX.Y.Z` release in a dedicated PR. Acquire the comparison once locally during Step 1, keeping original assertions outside the checkout. After merge and native CI, verify that acquisition against the final clean -commit before tagging. Create a draft, stage accepted records, and dispatch +commit before tagging. The tag push prepares a draft automatically. Stage accepted records and dispatch `Release Benchmarks` to verify and attach the original archive. Publish the reviewed draft, explicitly dispatch `Publish to crates.io` with the independent publication selection, then approve its `crates-io` deployment. -Both workflows require the maintainer's authenticated dispatch against the exact -release tag. Neither measures benchmarks. Development supplier pins support +Benchmark attachment and Cargo publication require the maintainer's authenticated +dispatch against the exact release tag. Preparation and both dispatches reuse evidence without measurements. +Development supplier pins support rehearsal and ordinary checks; actual release work requires published tooling. The changelog is generated for the target tag before the tag exists, so the @@ -30,6 +31,7 @@ including when a rerun happens after UTC midnight. ## Contents - [Reusable CI evidence](#reusable-ci-evidence) +- [Release command alignment](#release-command-alignment) - [Prepare the environment](#prepare-the-environment) - [Step 1: Prepare the release PR](#step-1-prepare-the-release-pr) - [Step 2: Publish after the PR is merged](#step-2-publish-after-the-pr-is-merged) @@ -50,7 +52,7 @@ The `release-evidence` job signs their exact commit, run, attempt, job IDs and package identity, then retains the manifest and Sigstore bundle in an immutable Actions artifact for 30 days. -`just release-evidence ` verifies original acquisition, +`just release-evidence` automatically resolves the original Step 1 owner and verifies original acquisition, the precise publisher receipt, final clean tree and signed native evidence. It requires one successful `push` run on `main`, every required job and step from that attempt, and a nonexpired immutable artifact. The shared verifier @@ -60,7 +62,12 @@ results cannot replace main-push evidence. Retain the owner directory printed by `performance-release` and an independent backup under exclusive maintainer control. Its default location is -`~/.local/state/la-stack/acquisitions/`. An ignored +`~/.local/state/la-stack/acquisitions//`. Step 1 +also freezes `owner.json` under `~/.local/state/la-stack/releases///`, +binding the original owner and acquisition/publication digests. Routine Step 2 +commands read that record and derive the full commit from the clean checkout; +they never search for the newest owner or reconstruct ownership from candidate files. +Retain both directories and an independent backup. An ignored checkout directory is not an authentication boundary. Original raw evidence, preparation, acquisition assertions, publication receipt and their digests are immutable. @@ -101,6 +108,38 @@ Use a current GitHub CLI with `gh attestation verify` and the signer/source constraints used by the installed verifier. See GitHub's [attestation verification options](https://cli.github.com/manual/gh_attestation_verify). +## Release command alignment + +The common Step 1/Step 2 flow follows the +[research-repo-tools v0.2.6 guide](https://github.com/acgetchell/research-repo-tools/blob/eb7e3eaf158024a045a6711898a8af199729131b/docs/RELEASING.md). +Draft preparation uses the public capability from upstream +[PR #207](https://github.com/acgetchell/research-repo-tools/pull/207), rehearsed here at +`cd8594f658b5bf3ade063a685834565cc62e0cb3`, including receipt retrieval from +[PR #209](https://github.com/acgetchell/research-repo-tools/pull/209). Published-package and final native adoption +remain required by [#333](https://github.com/acgetchell/la-stack/issues/333). + +| Command | Common behavior and la-stack declaration | +| --- | --- | +| `update-version TAG [ARGS...]` | Prepare package/citation metadata; Cargo owns the Rust lock, the private Python environment retains its version | +| `changelog-release TAG [DATE]` | Generate prospective notes using the prepared citation date | +| `release-version-check` | Require synchronized final metadata, changelog and citation dates | +| `publish-check` | Verify clean Cargo source with default/exact dry-runs; Cargo requires builds here, unlike the reference's existing-distribution Twine check | +| `release-evidence [OWNER] [SELECTION]` | Inspect retained signed main CI and original benchmark acceptance without builds; defaults recover the original owner and `reviewed` selection | +| `tag TAG` | Run the final version check and create the annotated tag from generated notes | +| `release-notes TAG` | Print generated notes; tag-triggered preparation supplies these to the draft automatically | +| `release-benchmarks TAG` | Dispatch retained archive attachment from the original owner record; explicit `CANDIDATE_TRANSFER_FILE` remains a recovery override | +| `release-publish TAG [OWNER] [SELECTION]` | Revalidate acceptance, notes and canonical assets, then explicitly approve GitHub publication | +| `release-upload TAG [OWNER] [SELECTION]` | Dispatch Cargo publication using the existing reviewed acceptance and original publication transfer | +| `release-verify TAG` | Verify stable GitHub assets and the exact crates.io version | + +This repository verifies evidence **before** creating or pushing the tag. The +reference's Python distribution inspection requires a pushed tag; that ordering +difference does not weaken la-stack's pre-tag gate. Preparation independently +revalidates its frozen native selection before creating an unpublished draft. +Benchmark acquisition and README generation belong only to Step 1. Cargo's +nonbuilding repackaging/upload, explicit transfer selection and `crates-io` +approval are the remaining registry and scientific-asset differences. + ## Prepare the environment Replace any development tooling pin with its supplying published release and @@ -125,7 +164,7 @@ Verify the repository remotes and synchronize `main`: ```bash gh auth status -git remote -v +git --no-pager remote -v git switch main git pull --ff-only ``` @@ -365,23 +404,24 @@ inputs, named metric/statistic/axis views, output ownership and methodology. ```bash just ci -cargo publish --locked --package la-stack --registry crates-io --allow-dirty --dry-run -cargo publish --locked --package la-stack --registry crates-io --allow-dirty --dry-run --features exact +just release-version-check ``` `just ci` includes the lockfile, citation, documentation, test, and benchmark compile checks. -The preparation dry-runs permit the uncommitted release artifacts. After review -and merge, main CI runs `just release-package` on the clean release source with -both default and `exact` features and explicitly selects only `la-stack`. -Those hosted results become the reusable packaging evidence. +The final version check requires the current changelog heading and citation date +to agree. After committing the reviewed artifacts below, run `just publish-check` +on that clean release branch. It performs native default/exact Cargo dry-runs, +selecting only `la-stack`. Main CI repeats this on the final merged commit; +those hosted results become the reusable packaging evidence. Step 2 reuses them +and never repeats package-verification builds. ### 7. Review, stage, and commit the release artifacts Inspect all changes before staging: ```bash -git status --short +git --no-pager status --short git --no-pager diff ``` @@ -402,6 +442,8 @@ git commit -m "chore(release): release $TAG - Generate the release changelog - Update benchmark and performance artifacts - Update release documentation" + +just publish-check ``` ### 8. Push the branch and open the PR @@ -424,25 +466,48 @@ hand-edit the generated changelog to add a known-issue note. ## Step 2: Publish after the PR is merged -### 1. Synchronize `main` +### 1. Synchronize `main` and wait for CI ```bash git switch main git pull --ff-only +test -z "$(git --no-pager status --porcelain)" ``` +Use a clean checkout of the reviewed release commit. If `main` advanced, review +the intervening changes and refresh generated notes through a PR as needed. +Wait for that commit's complete main-push CI run. The next step derives the commit +and checks eligible evidence automatically; pending results stop it with a retry +diagnostic. Use GitHub's Actions page or the +[manual inspection commands](#manual-context-and-inspection) when troubleshooting. + +Require the complete run: all three native `build` jobs, default/exact Cargo +packaging in `release-package`, and signed `release-evidence`. A development +supplier pin skips signing and cannot provide eligible release evidence. +Inspect the independent SARIF checks for this exact commit too: + +```bash +gh run list --workflow rust-clippy.yml --event push --branch main --commit "$(git --no-pager rev-parse HEAD)" --limit 20 +gh run list --workflow semgrep-sarif.yml --event push --branch main --commit "$(git --no-pager rev-parse HEAD)" --limit 20 +``` + +Wait for successful `Clippy SARIF Analysis` and `Repository Rule SARIF Analysis` +from GitHub Actions (app ID `15368`). They are separate publication gates; +`release-evidence` verifies only the jobs and signer declared in its evidence +policy, not every independent workflow. PR review remains a protected-branch +requirement and cannot replace merged main-push evidence. + ### 2. Verify the final release commit -Set `OWNER` to the independent directory printed during Step 1: +The normal commands need no `OWNER` or `RELEASE_COMMIT` shell variables: ```bash -OWNER="/path/to/independent/acquisition" -just release-evidence "$OWNER" -just release-archive "$OWNER" +just release-evidence +just release-archive ``` -Require native Linux, macOS and Windows CI, default/exact Cargo packaging, -signed main-push evidence and exact-commit SARIF checks. The candidate verifier +Require native Linux, macOS and Windows CI, default/exact Cargo packaging and +signed main-push evidence, plus the independently checked SARIF results. The candidate verifier also checks that the final tree differs from the measured candidate only through the accepted publication contract. It preserves the original measured revision separately from the final commit. Do not tag until verification succeeds. @@ -456,27 +521,41 @@ evidence. Pending, failed, cancelled or expired evidence blocks publication. ```bash just tag "$TAG" git --no-pager tag -l --format='%(contents)' "$TAG" +test "$(git --no-pager rev-parse "$TAG^{commit}")" = "$(git --no-pager rev-parse HEAD)" ``` -`just tag` builds the annotation from the matching active or archived changelog +`just tag` first runs `release-version-check` and revalidates the existing reviewed +acceptance for clean HEAD. A different commit or missing receipt requires an +explicit `release-evidence` review before tagging. It then builds the annotation from the matching active or archived changelog section. For a changelog larger than 125 kB, it links that section. ### 4. Push the tag +Only after evidence verification and tag inspection succeed, push the tag. +This starts `Prepare GitHub release`; pushing does not wait for or verify CI. + ```bash git push origin "$TAG" ``` -### 5. Create the draft GitHub release - -Create an unpublished release where the workflow can attach its benchmark archive: - -```bash -NOTES="$(mktemp)" -just release-notes "$TAG" > "$NOTES" -gh release create "$TAG" --verify-tag --draft --title "$TAG" --notes-file "$NOTES" -rm "$NOTES" -``` +### 5. Wait for the draft GitHub release + +The tag push starts `prepare-release.yml`. Let its original attempt finish; +the next `release-stage` command automatically retrieves and verifies that exact +preparation receipt. Missing or pending preparation stops staging; wait for the +original run and retry. The manual inspection commands below can show that run. +Let preparation create the draft; manual creation is unnecessary. +The read-only job checks the exact tagged source, main ancestry, final metadata +and retained signed native evidence, then freezes that selection and generated +notes. An isolated pinned writer with no checkout authenticates the handoff by +immutable artifact ID, digest, run and attempt, revalidates the same native +selection and creates the unpublished draft through the shared API. + +Retain the handoff and separate draft-receipt artifact IDs, ZIP digests and +producer run/attempts in independent release review notes. Preserve the captured +release ID in `target.json`. An existing exact matching draft is unchanged; +conflicting notes, title, lifecycle, moved tags or missing evidence stop work. +No tests, package builds, signing or benchmark acquisition run during preparation. Keep the release title identical to the tag, including its leading `v`. Leave it as a stable draft: publishing now would make the release immutable @@ -488,18 +567,30 @@ before the benchmark archive can be attached. GitHub recommends this Stage the four inert records: ```bash -just release-stage "$OWNER" +just release-stage ``` -This revalidates acceptance, freezes the draft ID before any upload, and prints +Staging uses the installed `release assets retrieve` operation and +`tooling/draft-receipt.toml`. It authenticates the exact tag-push workflow source, +original successful attempt, required jobs/steps, immutable artifact ID and ZIP +digest before retaining `selection.json`, `target.json` and `draft-receipt.json` +under the owner's final record. Retries revalidate that frozen provider selection +instead of searching again. A replacement draft is rejected before any upload. + +This revalidates acceptance, freezes the prepared draft ID before any upload, and prints the independent `transfer.json` path. A retry uses that same draft and identical bytes. Dispatch from the exact tag using the printed path: ```bash -TRANSFER="$OWNER/final/$(git rev-parse HEAD)/reviewed/transfer.json" -gh workflow run release-benchmarks.yml --ref "$TAG" -f tag="$TAG" -F candidate=@"$TRANSFER" +just release-benchmarks "$TAG" ``` +The recipe resolves the existing transfer from the original owner record and +clean HEAD, validates the explicitly supplied tag, and returns after dispatch. +It never substitutes another owner or tag, polls, creates a draft, +acquires measurements or runs `performance-readme`. Inspect the matching +`Release Benchmarks` run and require success before publishing. + The no-checkout retrieval job checks original release/asset identities and downloads the selected bytes. A separate read-only checkout job verifies acquisition, final binding and full frozen native acceptance. The no-checkout @@ -521,7 +612,7 @@ pinned tooling and execute no checkout code. From the clean tagged checkout and your human GitHub CLI session: ```bash -just release-publish "$TAG" "$OWNER" +just release-publish "$TAG" ``` This verifies acceptance and remote notes, freezes the canonical attached archive @@ -529,19 +620,20 @@ ID/hash in `publication-transfer.json`, then publishes the reviewed draft through the shared gate. It authorizes GitHub Release publication. The release must become immutable before Cargo publication. -Supply that independently retained selection to the second explicit dispatch: +Dispatch Cargo publication from that independently retained selection: ```bash -just release-evidence "$OWNER" -# Copy the printed SHA-256 of the full acceptance after reviewing its selection. -ACCEPTANCE_SHA="" -PUBLICATION="$OWNER/final/$(git rev-parse HEAD)/reviewed/publication-transfer.json" -gh workflow run publish-crates.yml --ref "$TAG" -f tag="$TAG" \ - -F candidate=@"$PUBLICATION" -f acceptance="$ACCEPTANCE_SHA" +just release-upload "$TAG" gh run list --workflow publish-crates.yml --limit 5 ``` -The first job checks immutable release state, canonical and transport asset +`release-upload` revalidates the already existing acceptance and original +publication transfer, checks the immutable release, and sends a snapshot of the +same assertions and acceptance digest. Missing review or changed evidence stops +dispatch; this command never creates a review selection or chooses a newer run. +It returns after dispatch and does not approve the deployment. + +The first hosted job checks immutable release state, canonical and transport asset IDs/hashes, original archive acceptance, final clean commit and generated notes. It verifies current native evidence against the separately reviewed full acceptance digest and freezes the exact receipt in an immutable Actions artifact. @@ -595,8 +687,83 @@ but execute no checkout code. See [workflow validation](BENCHMARKING.md#validate-the-release-workflow) for the exact handoff, local regressions and separate hosted evidence requirements. +### Manual context and inspection + +The normal commands recover context after restarting the shell. Explicit paths, +selections and GitHub commands remain available for older acquisitions, a moved +checkout or deliberate recovery. They do not replace or rewrite the normal +external owner selection. Do not infer missing assertions from received assets. + +```bash +RELEASE_COMMIT=$(git --no-pager rev-parse HEAD) +OWNER="/path/to/original/independent/acquisition" +just release-evidence "$OWNER" +just release-archive "$OWNER" +``` + +The same explicit owner and optional selection arguments are supported by +`tag`, `tag-force`, `release-publish` and `release-upload` during recovery. + +Use the recorded workflow/run or inspect the exact current commit: + +```bash +gh run list --workflow ci.yml --event push --branch main --commit "$RELEASE_COMMIT" --limit 20 +gh run list --workflow prepare-release.yml --event push --branch "$TAG" --commit "$RELEASE_COMMIT" --limit 20 +gh run watch ORIGINAL_RUN_ID --exit-status +``` + +For manually reviewed preparation retrieval, the shared CLI preserves the same +source, original-attempt, job and artifact checks: + +```bash +just release-assets retrieve tooling/draft-receipt.toml --tag "$TAG" --commit "$RELEASE_COMMIT" \ + --run-id ORIGINAL_PREPARATION_RUN_ID --output /path/to/independent/preparation +just release-stage "$OWNER" /path/to/independent/preparation/target.json +export CANDIDATE_TRANSFER_FILE="$OWNER/final/$RELEASE_COMMIT/reviewed/transfer.json" +just release-benchmarks "$TAG" +just release-publish "$TAG" "$OWNER" +just release-upload "$TAG" "$OWNER" +``` + +For direct dispatch troubleshooting, `publish-crates.yml` still accepts +`--ref "$TAG" -f tag="$TAG" -F candidate=@/original/publication-transfer.json` +and `-f acceptance=REVIEWED_FULL_ACCEPTANCE_SHA256`. The explicit selection must +be the same original owner record; do not copy a digest from incoming assets. + ### Recovering a failed run +If HEAD changes, return to Step 2's synchronization and exact-commit verification. +If a native run/attempt changes, explicitly review a new named selection as +described below. Missing, expired, PR, dispatch or partial-rerun evidence never +authorizes fallback to a replacement run or a local rebuild. + +#### Draft preparation recovery + +Automatic preparation accepts only its original attempt and rejects prior runs +for the same tag. Do not rerun a failed preparation or recreate its tag to obtain +a fresh receipt. Preserve the original authenticated handoff and journal on +success and failure. A pending journal means creation may have happened; a +captured journal binds the existing release ID. A missing or edited draft is not +permission to create a replacement. + +Recover the exact original artifacts by independently recorded immutable IDs, +verify their ZIP digests and producer run/attempts, and retain the original policy, +selection, notes and receipt. Follow the supplying package's +[recovery](https://github.com/acgetchell/research-repo-tools/blob/cd8594f658b5bf3ade063a685834565cc62e0cb3/docs/PREPARING_DRAFTS.md#retry-and-recovery). +After independent provider/audit review, its `release assets prepare` CLI accepts +`--recover-release-id REVIEWED_ID` with the original pending receipt and frozen +inputs; recovery performs no POST. A captured ID cannot be replaced. This is an +explicit maintainer recovery, not another automatic preparation attempt. + +The workflow retains records for 30 days; hard runner termination or upload +failure may prevent journal retention. GitHub history guards are not a permanent +intent ledger. Keep workflow identity/history intact for retriggerable tags. If +history or the journal is unavailable, stop automation and reconcile intent and +provider state manually before authorizing any further creation. Recovery writers +must use the same repository/tag serialization group as preparation and attachment. + +#### Benchmark and publication recovery + Resume local acquisition with `just performance-resume`; keep the original checkpoint, preparation and owner record. Retry capture, report promotion and README publication separately after acquisition completes. Source/harness drift @@ -625,8 +792,9 @@ verification. These retries perform no timings. Upload retries reuse the original draft ID and identical bytes. Conflicting or incomplete assets stop without overwriting or deletion. Inspect failures -before manual correction. A replacement draft needs a new explicit review -selection. Never move a published tag or replace independent digests with values +before manual correction. A replacement draft requires explicitly authorized +preparation recovery and an authenticated new target; a new review selection +alone does not authorize replacement. Never move a published tag or replace independent digests with values from received files. A rerun uses the tagged workflow. A later workflow fix requires a reviewed new diff --git a/docs/code_organization.md b/docs/code_organization.md index c038af2..fe28e64 100644 --- a/docs/code_organization.md +++ b/docs/code_organization.md @@ -279,6 +279,12 @@ original archive derivation, exact draft staging and authenticated transfer. It does not implement a comparison engine, archive parser or CI verifier. `tests/tooling/test_candidate_transfer.py` exercises actual privileged workflow blocks and exact selection/credential boundaries. +`tests/tooling/test_release_flow.py` exercises canonical dispatch and automated +draft preparation through the installed shared API. `tests/tooling/test_validation_graph.py` +checks parallel/serial validator ownership, shared setup, failure propagation +and the managed compilation-cache boundary. +`tests/tooling/test_release_context.py` owns automatic external owner selection, +authenticated original draft-receipt retrieval and reviewed publication dispatch. Working-tree measurements now include nonignored untracked regular files. `tests/tooling/test_performance_workflow.py` exercises actual recipes and scientific policy. Shared failure matrices remain upstream. The @@ -309,6 +315,13 @@ native/Cargo and signer policy to the installed verifier; asset commands from `RUNNER_TEMP`, without executing checkout code. `.github/workflows/release-benchmarks.yml` separates no-checkout draft retrieval, read-only candidate verification and no-checkout attachment. It does not measure. +`.github/workflows/prepare-release.yml` owns tag-triggered read-only native +evidence inspection and isolated draft creation. Its authenticated inert handoff +and durable journal use the installed draft API; no local creation engine exists. +`tooling/draft-receipt.toml` declares the original tag-push workflow and required +receipt-producing jobs/steps for the installed retrieval API. +The [integration record](dev/release-flow-integration.md) maps command/validation +ownership and records baseline timings and remaining adoption evidence. `.github/workflows/publish-crates.yml` consumes a second maintainer dispatch with independent canonical-asset and full acceptance assertions. It owns approval, the immutable approval receipt, explicit native-evidence renewal, revalidation diff --git a/docs/dev/release-flow-integration.md b/docs/dev/release-flow-integration.md new file mode 100644 index 0000000..9bf8901 --- /dev/null +++ b/docs/dev/release-flow-integration.md @@ -0,0 +1,134 @@ +# Release and validation integration + +Implementation record for [#330](https://github.com/acgetchell/la-stack/issues/330) +and [#338](https://github.com/acgetchell/la-stack/issues/338). The starting source +is `7c3a3625220773369c089e64e568a979abc0bb7f`. The authorized supplying revision is +research-repo-tools `cd8594f658b5bf3ade063a685834565cc62e0cb3` (PRs #207 and #209). +Its installed metadata remains 0.2.6; the Git commit, manifest, lock and isolated +runtime assertions distinguish this development rehearsal from publication. + +## Release contract + +The [release command comparison](../RELEASING.md#release-command-alignment) +records the v0.2.6 reference revision, common operator commands and Cargo/benchmark +differences. The eligible final-main attempt owns native tests and default/exact +packaging. Step 2 inspects retained evidence before tagging. Tag-triggered draft +preparation uses the supplier's signed-selection and durable intent API; the +read-only job passes inert notes and evidence to an isolated writer through an +authenticated immutable artifact. Initial staging requires the authenticated +preparation target and never discovers a replacement draft. The supplier's +tag-aware receipt retrieval freezes the original provider run/attempt and artifact +before using its target. Step 1 registers one immutable external owner selection +per checkout and tag. Routine commands recover that owner and derive clean HEAD; +explicit owner/target/transfer inputs remain manual recovery options. + +Generic draft lifecycle and retry logic remain upstream. Existing owner records, +scientific workloads, raw measurements, generated reports, archive identity, +publication approval and nonbuilding Cargo upload remain separate responsibilities. +The development-pin gate still prevents acquisition for release, signed evidence +creation and publication until a published supplying package is adopted. + +## Validation graph + +Previously `ci` listed all validators sequentially. The new graph runs shared +`python-sync` and `tools-check`, then `_ci-policy`, then the two dependencies of +`[parallel] _ci-execution`. One Just invocation coalesces shared prerequisites; +there are no nested Just processes for lane orchestration. `just --jobs 1 ci` +serializes the same graph. +This uses [Just's parallel dependency scheduling](https://just.systems/man/en/parallelism.html). + +| Owner | Existing validators retained | +| --- | --- | +| Shared setup | `python-sync`, `tools-check`, their prerequisite checks | +| `_ci-policy` | `action-lint`, `workflow-allowlist-check`, `zizmor`, Markdown/spelling, private nonfinal version check, changelog, TOML, YAML, citation, JSON, Just formatting, Python static checks, Cargo lock/formatting, Semgrep/fixtures, unused dependencies, shell checks | +| `_ci-rust` | `clippy-all-targets`, `doc-check`, `test-rust-ci`, `test-doc`, `test-doc-exact`, `bench-compile`, `test-bench-exact`, `examples` | +| Parallel Python lane | `test-python`, with explicit `--no-sync` after shared setup | +| Separate main-CI packaging job | `publish-check` (formerly recipe `release-package`); hosted job and step names remain unchanged | +| Explicit acquisition | No timing recipe joins validation; benchmark collection remains opt-in and isolated | + +All root Cargo output users stay sequential. Python consumer fixture workspaces +remain isolated. No source, installed-distribution or fresh-setup matrix is +invented for this private dependency-only Python environment. There was no +consumer fresh-install smoke job to remove. Existing native Linux/macOS/Windows +`build` jobs, `Run CI checks`, `release-package`, its packaging step, signer +dependencies and signer steps remain unchanged, so evidence policy and required +check names require no migration. + +`tests/tooling/test_validation_graph.py` executes the real graph with inert +validator bodies. It requires identical complete coverage and one setup in +parallel and serial modes, proves lane overlap, and injects failures before and +inside each lane. The native scientific tests and package checks still run +through the owning recipes; graph fixtures do not replace them. + +## Managed compilation cache + +The supplying package owns the local Cargo build-cache implementation and exact +tool installation verification. Ordinary CI's existing setup action now caches +only `RUNNER_TEMP/rrt-cargo-build`, supplied through the documented absolute +`RESEARCH_REPO_TOOLS_CARGO_BUILD_HOME` override. Exact-version installation homes +under `RUNNER_TEMP/rrt` are separate and freshly verified. Cache keys include OS, +architecture, Rust toolchain, Python/tool declarations and both locks, without +broad restore prefixes. GitHub's branch/PR cache scopes remain in force; PR +entries cannot replace protected main entries. No remote cache crosses those +provider boundaries. This is compilation reuse, not validation reuse. + +Release packaging, preparation, evidence signing, benchmark attachment and Cargo +publication keep caches disabled. No compiler flags, scientific inputs or +benchmark directories change. + +## Timing evidence and limits + +Baseline native data comes from successful main-push +[CI run 42443172694, attempt 1](https://github.com/acgetchell/la-stack/actions/runs/42443172694/attempts/1) +on the starting source. Durations below use API job/step timestamps and are +descriptive wall time, not benchmark results or a cold/warm comparison. + +| Job | Queue from run start | Tool setup | CI or packaging step | Total job wall time | +| --- | --- | --- | --- | --- | +| Linux build | 3 s | 23 m 51 s | 26 m 27 s | 50 m 40 s | +| macOS build | 4 s | 41 m 59 s | 36 m 27 s | 80 m 35 s | +| Windows build | 3 s | 1 m 49 s | 62 m 18 s | 66 m 57 s | +| Release packaging | 3 s | 21 m 23 s | 4 s | 21 m 35 s | +| Evidence job | Waited for native jobs | — | Development-pin signing/upload skipped | 14 s | + +The complete workflow occupied 80 m 56 s from creation to completion. The four +build/package job intervals total 219 m 47 s of runner wall time; this is distinct +from critical-path duration and excludes provider billing/rounding. Setup cache +hit state was not established, so none of these observations is labelled cold +or warm. The evidence job's success does not establish signed release evidence: +its development-pin signing and artifact upload steps were skipped. + +Locally, the original `just check` graph at the new exact supplier pin completed +in 52.67 s elapsed (27.52 s user, 13.11 s system) on the existing macOS environment +before recipe restructuring. This is a warm development environment, not a clean +managed-install experiment. It is not directly comparable to full hosted CI. + +Final local validation at the supplying revision above passed on macOS 27.2, +arm64, Python 3.15 and Rust 1.99.1, using the existing development environment +and four Python test workers: + +| Command | Elapsed | User CPU | System CPU | Python suite | +| --- | --- | --- | --- | --- | +| `just --jobs 1 ci` | 905.35 s | 2,152.17 s | 365.49 s | 438 passed in 676.01 s | +| `just ci` | 736.74 s | 1,640.82 s | 340.51 s | 438 passed in 691.83 s | + +Both runs completed the same validators, including 1,143 Rust tests, default/exact +doctests, benchmark compilation, the exact harness in test mode and examples. +The serial run compiled missing targets; the later parallel run reused them. +These are descriptive observations with different compilation cache states, +not an isolated measurement of parallelism or managed-cache speedup. The long +synthetic acquisition/acceptance consumer is a remaining bottleneck: its call +alone took 525.65 s in the serial run. No scientific measurements were acquired. + +The focused owner/receipt/dispatch and graph regressions passed, including real +Just rejection of publication without prior review. Windows-targeted static +type checking and the exact lock check passed. Astra's source review found no +remaining trust-boundary blocker; it did not substitute for native validation. + +New native CI, authenticated hosted retained-evidence handoff and matched cold/warm managed +installation timings require the final reviewed commit. No speedup is claimed +from configuration, graph-fixture timing or these unmatched cache observations. +[#333](https://github.com/acgetchell/la-stack/issues/333) retains final published +package/native release adoption; #338 remains open for its native timing and +published-package acceptance until those are demonstrated. No release, tag or +benchmark campaign is run as part of this integration. diff --git a/justfile b/justfile index eca238e..1ad12de 100644 --- a/justfile +++ b/justfile @@ -24,6 +24,19 @@ _coverage_base_args := '''--features exact \ --workspace --lib --tests \ --verbose''' +# All parallel work shares the completed policy/setup phase in this invocation. +[parallel] +[private] +_ci-execution: _ci-rust test-python + +# Cheap policy failures stop before either execution lane starts. +[private] +_ci-policy: action-lint workflow-allowlist-check zizmor markdown-check spell-check _version-check changelog-check toml-parse-check toml-fmt-check toml-lint yaml-fmt-check yaml-lint citation-check validate-json justfile-fmt-check python-check cargo-lock-check fmt-check semgrep semgrep-test unused-deps shell-check + +# Keep root Cargo outputs and compilation reuse in one sequential lane. +[private] +_ci-rust: clippy-all-targets doc-check test-rust-ci test-doc test-doc-exact bench-compile test-bench-exact examples + # List all public recipes with their arguments and descriptions. [default] [private] @@ -57,7 +70,7 @@ _ensure-yamllint: _ensure-uv # Isolate the supplier runtime from the observed candidate checkout. [positional-arguments] [private] -[script("uvx", "--python", "3.15", "--from", "research-repo-tools @ git+https://github.com/acgetchell/research-repo-tools@055353c630604b1ae3438c1eaf534ba85e067a7e", "python", "-I")] +[script("uvx", "--python", "3.15", "--from", "research-repo-tools @ git+https://github.com/acgetchell/research-repo-tools@cd8594f658b5bf3ade063a685834565cc62e0cb3", "python", "-I")] _release-candidate *args: import runpy from pathlib import Path @@ -75,6 +88,11 @@ _release-notes-check tag: 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 +# Ordinary checks accept unreleased history; final release validation is stricter. +[private] +_version-check: python-sync + uv run --locked --group dev research-repo-tools release check + # GitHub Actions workflow validation action-lint: _ensure-actionlint uv run --locked research-repo-tools files run --include '.github/workflows/*.yml' --include '.github/workflows/*.yaml' -- actionlint @@ -242,7 +260,7 @@ check-fast: {{ _run }} cargo check # Run the complete local CI validation, tests, examples, and benchmark compilation. -ci: action-lint workflow-allowlist-check zizmor markdown-check spell-check docs-version-check changelog-check toml-parse-check toml-fmt-check toml-lint yaml-fmt-check yaml-lint citation-check validate-json justfile-fmt-check python-check test-python cargo-lock-check fmt-check clippy-all-targets doc-check semgrep semgrep-test unused-deps shell-check test-rust-ci test-doc test-doc-exact bench-compile test-bench-exact examples +ci: python-sync tools-check _ci-policy _ci-execution @echo "🎯 CI checks complete!" # Validate CITATION.cff against the Citation File Format schema. @@ -295,10 +313,6 @@ doc-check: RUSTDOCFLAGS='-D warnings' {{ _run }} cargo doc --no-deps RUSTDOCFLAGS='-D warnings' {{ _run }} cargo doc --no-deps --features exact -# Check synchronized release metadata without changing files. -docs-version-check: python-sync - uv run --locked --group dev research-repo-tools release check - # Build and run all library examples, including exact arithmetic. examples: {{ _run }} research-repo-tools validation cargo-examples tooling/examples.toml @@ -335,7 +349,7 @@ lint-code: rust-core-check python-check shell-check lint-config: validate-json toml-check yaml-check github-actions-check justfile-fmt-check # Check Markdown, spelling, release metadata, and generated changelogs. -lint-docs: markdown-ci docs-version-check changelog-check +lint-docs: markdown-ci _version-check changelog-check # Check active Markdown formatting, local links, and line lengths. markdown-check: tools-check _ensure-uv @@ -414,6 +428,11 @@ performance-resume checkpoint="target/bench-reports/full/checkpoint" progress="h plot-vs-linalg current_tag current_revision baseline_tag baseline_revision view="lu_solve-median-linear" input="retained": python-sync {{ _run }} uv run --locked research-repo-tools performance publish {{ quote("tooling/plots/" + input + "/" + view + ".toml") }} --artifacts-only --current-release {{ quote(current_tag) }} --current-revision {{ quote(current_revision) }} --baseline-release {{ quote(baseline_tag) }} --baseline-revision {{ quote(baseline_revision) }} +# Verify clean Cargo source packaging with default and exact features, without upload. +publish-check: + {{ _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 + # Check Python formatting, lint, fixtures, and types. python-check: python-sync uv run --locked research-repo-tools python check @@ -447,26 +466,33 @@ python-typecheck: python-sync uv run --locked research-repo-tools python typecheck # Derive the already accepted original acquisition without measuring or building. -release-archive owner selection="reviewed": (_release-candidate "archive" owner "--selection" selection) +release-archive owner="" selection="reviewed": (_release-candidate "archive" owner "--selection" selection) -# Preflight, upload or verify a captured draft target with an isolated trusted runtime. +# Use explicit shared asset/recovery operations with an isolated trusted runtime. [positional-arguments] -[script("uvx", "--python", "3.15", "--from", "research-repo-tools @ git+https://github.com/acgetchell/research-repo-tools@055353c630604b1ae3438c1eaf534ba85e067a7e", "python", "-I")] -release-assets operation tag *args: +[script("uvx", "--python", "3.15", "--from", "research-repo-tools @ git+https://github.com/acgetchell/research-repo-tools@cd8594f658b5bf3ade063a685834565cc62e0cb3", "python", "-I")] +release-assets +args: import sys - from pathlib import Path from research_repo_tools.cli import main - from research_repo_tools.process import run_git_command - root = Path.cwd() - commit = run_git_command(["--no-pager", "rev-parse", "HEAD"], cwd=root, timeout=30).stdout.strip() - sys.exit(main(["release", "assets", sys.argv[1], "tooling/release-assets.toml", "--checkout", str(root), "--commit", commit, "--tag", sys.argv[2], *sys.argv[3:]])) + sys.exit(main(["release", "assets", *sys.argv[1:]])) + +# Dispatch retained benchmark attachment using the original Step 1 owner selection. +release-benchmarks tag: _ensure-gh _ensure-jq + #!/usr/bin/env bash + set -euo pipefail + tag={{ quote(tag) }} + [[ "$tag" =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]] || { echo "A canonical stable tag is required." >&2; exit 2; } + transfer="${CANDIDATE_TRANSFER_FILE-$(just --quiet _release-candidate record transfer)}" + [[ -f "$transfer" && -r "$transfer" ]] || { echo "The selected transfer must be a readable file." >&2; exit 2; } + jq -e --arg tag "$tag" '.schema == 1 and .target.repository == "acgetchell/la-stack" and .target.tag == $tag and .target.expected_title == $tag' -- "$transfer" >/dev/null + gh workflow run release-benchmarks.yml --repo acgetchell/la-stack --ref "$tag" -f "tag=$tag" -F "candidate=@$transfer" # Verify upstream tooling publication before release preparation or acquisition. release-dependencies: uv run --locked --no-sync research-repo-tools release dependencies # Bind the original local acquisition to clean HEAD and signed native CI. -release-evidence owner selection="reviewed": (_release-candidate "verify" owner "--selection" selection) +release-evidence owner="" selection="reviewed": (_release-candidate "verify" owner "--selection" selection) # Require the release event, reviewed commit, native checks, assets, and notes. release-gate tag: @@ -476,7 +502,7 @@ release-gate tag: # Verify signed native/Cargo evidence; accept --expected PATH and --github-output PATH. [positional-arguments] -[script("uvx", "--python", "3.15", "--from", "research-repo-tools @ git+https://github.com/acgetchell/research-repo-tools@055353c630604b1ae3438c1eaf534ba85e067a7e", "python", "-I")] +[script("uvx", "--python", "3.15", "--from", "research-repo-tools @ git+https://github.com/acgetchell/research-repo-tools@cd8594f658b5bf3ade063a685834565cc62e0cb3", "python", "-I")] release-native-evidence *args: import sys import tempfile @@ -493,16 +519,11 @@ release-native-evidence *args: 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 - # Freeze the canonical attached archive ID and bytes before publishing the draft. -release-publication owner selection="reviewed": (_release-candidate "seal" owner "--selection" selection) +release-publication owner="" selection="reviewed": (_release-candidate "seal" owner "--selection" selection) # Approve the benchmark-equipped draft as the authenticated maintainer. -release-publish tag owner selection="reviewed": (_release-notes-check tag) (release-evidence owner selection) (release-publication owner selection) +release-publish tag owner="" selection="reviewed": (_release-notes-check tag) (_release-candidate "reviewed" owner "--selection" selection) (release-publication owner selection) uv run --locked --no-sync research-repo-tools release publish {{ quote(tag) }} --approve # Verify the maintainer's dispatch input; never acquire timings or build packages. @@ -511,12 +532,19 @@ release-receive destination *args: just _release-candidate receive "$@" # Stage accepted inert assets and print the independently retained dispatch record. -release-stage owner selection="reviewed": (_release-candidate "stage" owner "--selection" selection) +release-stage owner="" target="" selection="reviewed": (_release-candidate "stage" owner "--selection" selection "--target" target) + +# Dispatch Cargo publication using the existing reviewed acceptance and immutable assets. +release-upload tag owner="" selection="reviewed": (_release-candidate "upload" tag owner "--selection" selection) # 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 +# Require final changelog, citation date and package metadata agreement. +release-version-check: python-sync + uv run --locked --group dev research-repo-tools release check --final-release + # 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) }} @@ -567,11 +595,11 @@ spell-check: tools-check {{ _run }} research-repo-tools files run --exclude typos.toml -- typos --config typos.toml --force-exclude -- # Create an annotated git tag from the CHANGELOG.md section for the given version -tag version: python-sync +tag version owner="" selection="reviewed": release-version-check (_release-candidate "reviewed" owner "--selection" selection) uv run --locked --group dev research-repo-tools changelog tag {{ quote(version) }} # Replace an existing local tag only after validating the release notes. -tag-force version: python-sync +tag-force version owner="" selection="reviewed": release-version-check (_release-candidate "reviewed" owner "--selection" selection) uv run --locked --group dev research-repo-tools changelog tag {{ quote(version) }} --force # Run library unit tests and default-feature doctests. @@ -610,7 +638,7 @@ test-integration-compile: tools-check # Run Python consumer integration and benchmark-policy tests. test-python: tools-check python-sync - {{ semgrep_stack_limit }} {{ _run }} uv run --locked pytest -q {{ _pytest_parallel }} --durations=20 + {{ semgrep_stack_limit }} {{ _run }} uv run --locked --no-sync pytest -q {{ _pytest_parallel }} --durations=20 # Run all runnable Rust tests and both doctest configurations. test-rust: test-rust-ci test-doc test-doc-exact diff --git a/pyproject.toml b/pyproject.toml index 337ff57..27c14ab 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -110,7 +110,7 @@ override-dependencies = [ # Temporary downstream rehearsal pin; return to PyPI after the supplying release. [tool.uv.sources] -research-repo-tools = { git = "https://github.com/acgetchell/research-repo-tools", rev = "055353c630604b1ae3438c1eaf534ba85e067a7e" } +research-repo-tools = { git = "https://github.com/acgetchell/research-repo-tools", rev = "cd8594f658b5bf3ade063a685834565cc62e0cb3" } [dependency-groups] tooling = [ "research-repo-tools[python-tools,semgrep]==0.2.6" ] diff --git a/tests/tooling/test_candidate_transfer.py b/tests/tooling/test_candidate_transfer.py index 97d5f70..4728fdc 100644 --- a/tests/tooling/test_candidate_transfer.py +++ b/tests/tooling/test_candidate_transfer.py @@ -176,13 +176,8 @@ def test_staging_retry_keeps_original_draft_before_any_upload(tmp_path: Path, mo monkeypatch.setattr(candidate, "archive", lambda *_args: final / "archive.tar.gz") monkeypatch.setattr(candidate, "parse_candidate_policy", lambda *_args: SimpleNamespace(tag=TAG)) original = ReleaseTarget(candidate.REPOSITORY, TAG, 42, SHA, TAG) - preflights: list[int] = [] - - def preflight(*_args: object, **_kwargs: object) -> ReleaseTarget: - preflights.append(42) - return original - - monkeypatch.setattr(candidate, "preflight_release_target", preflight) + prepared = tmp_path / "prepared-target.json" + prepared.write_bytes(serialize_release_target(original)) monkeypatch.setattr(candidate, "revalidate_release_target", lambda *_args, **_kwargs: None) def interrupted(*_args: object, **_kwargs: object) -> None: @@ -192,7 +187,7 @@ def interrupted(*_args: object, **_kwargs: object) -> None: monkeypatch.setattr(candidate, "publish_release_asset", interrupted) with pytest.raises(OSError, match="interrupted upload"): - candidate.stage(root, owner, "reviewed") + candidate.stage(root, owner, "reviewed", prepared) def replaced(_root: Path, target: ReleaseTarget, **_kwargs: object) -> None: assert target == original @@ -202,8 +197,10 @@ def replaced(_root: Path, target: ReleaseTarget, **_kwargs: object) -> None: monkeypatch.setattr(candidate, "revalidate_release_target", replaced) monkeypatch.setattr(candidate, "publish_release_asset", lambda *_args, **_kwargs: pytest.fail("replacement draft must never receive an upload")) with pytest.raises(ValueError, match="original draft no longer exists"): - candidate.stage(root, owner, "reviewed") - assert preflights == [42] + candidate.stage(root, owner, "reviewed", prepared) + prepared.write_bytes(serialize_release_target(replace(original, release_id=43))) + with pytest.raises(ValueError, match="immutable output differs"): + candidate.stage(root, owner, "reviewed", prepared) def test_owner_records_reject_overwrite_and_path_escape(tmp_path: Path) -> None: @@ -217,6 +214,37 @@ def test_owner_records_reject_overwrite_and_path_escape(tmp_path: Path) -> None: assert not (tmp_path / "other").exists() +@pytest.mark.parametrize("field", ["repository", "tag", "commit", "expected_title", "release_id"]) +def test_initial_stage_requires_the_exact_prepared_target(tmp_path: Path, monkeypatch: pytest.MonkeyPatch, field: str) -> None: + root, owner = tmp_path / "checkout", tmp_path / "owner" + root.mkdir() + owner.mkdir() + final = candidate.final_directory(owner, SHA, "reviewed") + final.mkdir(parents=True) + monkeypatch.setattr(candidate, "verify", lambda *_args: final / "acceptance.json") + monkeypatch.setattr(candidate, "resolve_revision", lambda *_args: SHA) + monkeypatch.setattr(candidate, "archive", lambda *_args: final / "archive.tar.gz") + monkeypatch.setattr(candidate, "parse_candidate_policy", lambda *_args: SimpleNamespace(tag=TAG)) + (final / "policy.json").write_bytes(b"owner policy") + (final / "acceptance.json").write_bytes(b"reviewed acceptance") + target = ReleaseTarget(candidate.REPOSITORY, TAG, 42, SHA, TAG) + changes = {"repository": "other/repo", "tag": "v9.9.9", "commit": "f" * 40, "expected_title": "edited", "release_id": 43} + supplied = replace(target, **{field: changes[field]}) + prepared = tmp_path / "target.json" + prepared.write_bytes(serialize_release_target(supplied)) + + def original(_root: Path, observed: ReleaseTarget, **_kwargs: object) -> None: + assert observed.release_id == 43 + message = "original prepared draft no longer exists" + raise ValueError(message) + + monkeypatch.setattr(candidate, "revalidate_release_target", original) + monkeypatch.setattr(candidate, "publish_release_asset", lambda *_args, **_kwargs: pytest.fail("a different prepared target must receive no uploads")) + diagnostic = "original prepared draft no longer exists" if field == "release_id" else "staging target differs" + with pytest.raises(ValueError, match=diagnostic): + candidate.stage(root, owner, "reviewed", prepared) + + @pytest.mark.parametrize("failure", ["none", "missing-attached", "changed-attached", "title", "mutable", "changed-acceptance", "reviewed-digest"]) def test_publication_pins_canonical_archive_and_full_acceptance( transfer: dict[str, Any], diff --git a/tests/tooling/test_justfile_discoverability.py b/tests/tooling/test_justfile_discoverability.py index f06a8f2..0d72f11 100644 --- a/tests/tooling/test_justfile_discoverability.py +++ b/tests/tooling/test_justfile_discoverability.py @@ -77,7 +77,7 @@ def test_saved_release_baseline_selects_only_public_workloads() -> None: def test_ci_enforces_full_python_fixture_lint_policy() -> None: """Canonical CI should lint fixtures without narrowing the Ruff configuration.""" recipes = just_recipes() - ci_dependencies = {dependency["recipe"] for dependency in recipes["ci"]["dependencies"]} + ci_dependencies = {dependency["recipe"] for dependency in recipes["_ci-policy"]["dependencies"]} python_body = json.dumps(recipes["python-check"]["body"]) assert "python-check" in ci_dependencies @@ -150,7 +150,7 @@ def test_dependabot_shared_caller_covers_dependencies_with_explicit_manual_revie if path.suffix in {".yml", ".yaml"} } # Publication and signed evidence production retain maintainer review. - manual_review = {".github/workflows/publish-crates.yml", ".github/workflows/ci.yml"} + manual_review = {".github/workflows/publish-crates.yml", ".github/workflows/ci.yml", ".github/workflows/prepare-release.yml"} assert manual_review <= actions assert set(policy["github_actions"]["files"]) == actions - manual_review diff --git a/tests/tooling/test_performance_workflow.py b/tests/tooling/test_performance_workflow.py index 7775172..2689c39 100644 --- a/tests/tooling/test_performance_workflow.py +++ b/tests/tooling/test_performance_workflow.py @@ -35,7 +35,7 @@ from research_repo_tools.suite_runs import comparison_incompatibilities, parse_suite_run from research_repo_tools.worktrees import capture_snapshot -from tooling.release_candidate import ReviewedAcceptance, archive, capture, freeze, receive, verify +from tooling.release_candidate import ReviewedAcceptance, archive, capture, freeze, receive, remember_owner, selected_owner, verify if TYPE_CHECKING: from collections.abc import Sequence @@ -245,7 +245,7 @@ def test_routine_ci_validates_benchmarks_without_acquiring_timings() -> None: assert any("--no-run --features bench,exact --bench exact" in line for line in compile_commands) assert [line for line in bench_commands if line not in compile_commands] == ['--locked --features bench,exact --bench exact -- "$@"'] recipes = inspect_justfile(ROOT).recipes - assert {"test-bench-exact", "bench-compile", "test-rust-ci"} <= {dep["recipe"] for dep in recipes["ci"]["dependencies"]} + assert {"test-bench-exact", "bench-compile", "test-rust-ci"} <= {dep["recipe"] for dep in recipes["_ci-rust"]["dependencies"]} smoke = recipes["test-bench-exact"]["dependencies"] assert [(dep["recipe"], dep["arguments"]) for dep in smoke] == [("bench-exact", ["--test"])] @@ -754,8 +754,7 @@ def no_acquisition(*_args: object, **_kwargs: object) -> None: assert (owner / "run.json").read_bytes() == (root / "target/bench-reports/full/run.json").read_bytes() assert (owner / "evidence.json").read_bytes() == (root / "target/bench-reports/full/evidence.json").read_bytes() original = {path.name: path.read_bytes() for path in owner.iterdir()} - assert capture(root, owner) == assertions - assert {path.name: path.read_bytes() for path in owner.iterdir()} == original + assert_owner_capture(root, owner, assertions, original, monkeypatch) publish_publication( load_report_plan( root, @@ -811,6 +810,16 @@ def native_evidence(*_args: object, expected: bytes | None = None, **_kwargs: ob assert artifact.read_bytes() == original_archive +def assert_owner_capture(root: Path, owner: Path, assertions: dict[str, str], original: dict[str, bytes], monkeypatch: pytest.MonkeyPatch) -> None: + """Retain the actual captured assertions while registering automatic lookup.""" + assert capture(root, owner) == assertions + assert {path.name: path.read_bytes() for path in owner.iterdir()} == original + with monkeypatch.context() as context: + context.setattr(Path, "home", lambda: owner.parent / "state home") + remember_owner(root, owner) + assert selected_owner(root) == owner.resolve() + + def assert_hosted_reuse(root: Path, owner: Path, accepted: Path, artifact: Path, monkeypatch: pytest.MonkeyPatch) -> None: """Verify the real archive and final binding through the hosted consumer.""" tmp_path = owner.parent diff --git a/tests/tooling/test_release_context.py b/tests/tooling/test_release_context.py new file mode 100644 index 0000000..39ce5e0 --- /dev/null +++ b/tests/tooling/test_release_context.py @@ -0,0 +1,363 @@ +"""Resolve original release ownership and receipt selections without shell state.""" + +import base64 +import hashlib +import json +import shlex +import subprocess +import sys +from copy import deepcopy +from pathlib import Path +from types import SimpleNamespace +from typing import Any + +import pytest +from research_repo_tools import release_interfaces, release_receipts +from research_repo_tools.process import run_command +from research_repo_tools.release_assets import ReleaseTarget, serialize_release_target +from research_repo_tools.release_drafts import RECEIPT_SCHEMA +from research_repo_tools.release_interfaces import load_receipt_policy + +from tooling import release_candidate as candidate + +ROOT = Path(__file__).resolve().parents[2] +TAG = "v0.5.42" +SHA = "a" * 40 + + +@pytest.fixture +def owner_record(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> tuple[Path, Path]: + root, owner = tmp_path / "checkout", tmp_path / "original owner" + root.mkdir() + owner.mkdir() + (root / "Cargo.toml").write_text('[package]\nname = "la-stack"\nversion = "0.5.42"\n', encoding="utf-8", newline="\n") + (owner / "policy.json").write_bytes(b"original policy") + (owner / "publication.json").write_bytes(b"original publisher receipt\r\n") + (owner / "acquisition.sha256").write_text("b" * 64, encoding="utf-8") + (owner / "publication.sha256").write_text(hashlib.sha256((owner / "publication.json").read_bytes()).hexdigest(), encoding="utf-8") + monkeypatch.setattr(Path, "home", lambda: tmp_path / "home") + # The complete acquisition fixture separately exercises the real policy parser/digest. + monkeypatch.setattr(candidate, "parse_candidate_policy", lambda _payload: SimpleNamespace(tag=TAG)) + monkeypatch.setattr(candidate, "acquisition_assertion_digest", lambda _policy: "b" * 64) + return root, owner + + +def test_owner_discovery_requires_the_original_step_one_registration(owner_record: tuple[Path, Path]) -> None: + root, owner = owner_record + with pytest.raises(ValueError, match="no original Step 1 owner selection"): + candidate.selected_owner(root) + assert not candidate.selection_directory(root).exists() + candidate.remember_owner(root, owner) + assert candidate.selected_owner(root) == owner.resolve() + retained = (candidate.selection_directory(root) / "owner.json").read_bytes() + other = owner.parent / "other owner" + other.mkdir() + for source in owner.iterdir(): + (other / source.name).write_bytes(source.read_bytes()) + assert candidate.selected_owner(root, str(other)) == other.resolve() + with pytest.raises(ValueError, match="immutable output differs"): + candidate.remember_owner(root, other) + assert (candidate.selection_directory(root) / "owner.json").read_bytes() == retained + + +@pytest.mark.parametrize("failure", ["tag", "checkout", "owner", "schema", "digest", "publication"]) +def test_owner_discovery_rejects_stale_or_changed_records(owner_record: tuple[Path, Path], failure: str) -> None: + root, owner = owner_record + candidate.remember_owner(root, owner) + path = candidate.selection_directory(root) / "owner.json" + data = json.loads(path.read_bytes()) + if failure == "digest": + (owner / "acquisition.sha256").write_text("c" * 64, encoding="utf-8") + elif failure == "publication": + (owner / "publication.json").write_bytes(b"changed receipt") + else: + data[failure] = {"tag": "v9.9.9", "checkout": "other", "owner": str(root), "schema": True}[failure] + path.write_text(json.dumps(data), encoding="utf-8", newline="\n") + with pytest.raises(ValueError, match=r"selection differs|separate from the checkout|assertions changed|publication receipt differs"): + candidate.selected_owner(root) + + +@pytest.fixture +def receipt_provider(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> dict[str, Any]: + """Keep the installed tag-aware verifier and immutable CLI output transaction.""" + root, owner = tmp_path / "checkout", tmp_path / "owner" + root.mkdir() + owner.mkdir() + (root / "Cargo.toml").write_text('[package]\nname = "la-stack"\nversion = "0.5.42"\n', encoding="utf-8", newline="\n") + (root / "tooling").mkdir() + (root / "tooling/draft-receipt.toml").write_bytes((ROOT / "tooling/draft-receipt.toml").read_bytes()) + policy = load_receipt_policy(root / "tooling/draft-receipt.toml") + target = ReleaseTarget(candidate.REPOSITORY, TAG, 42, SHA, TAG) + target_bytes = serialize_release_target(target) + receipt = json.dumps( + { + "schema": RECEIPT_SCHEMA, + "repository": candidate.REPOSITORY, + "tag": TAG, + "commit": SHA, + "title": TAG, + "notes_sha256": "b" * 64, + "selection_sha256": "c" * 64, + "state": "captured", + "release_id": 42, + } + ).encode() + repository = {"id": 11, "full_name": candidate.REPOSITORY} + run = { + "id": 77, + "run_attempt": 1, + "workflow_id": 9, + "path": policy.workflow, + "name": policy.workflow_name, + "event": "push", + "head_branch": TAG, + "head_sha": SHA, + "pull_requests": [], + "repository": repository, + "head_repository": repository, + "status": "completed", + "conclusion": "success", + } + artifact = { + "id": 88, + "name": "draft-receipt-77-1", + "digest": "sha256:" + "d" * 64, + "expired": False, + "size_in_bytes": 512, + "workflow_run": {"id": 77, "head_branch": TAG, "head_sha": SHA, "repository_id": 11, "head_repository_id": 11}, + } + jobs = [ + { + "id": index, + "name": name, + "run_id": 77, + "run_attempt": 1, + "head_branch": TAG, + "head_sha": SHA, + "status": "completed", + "conclusion": "success", + "labels": ["ubuntu-latest"], + "steps": [{"name": step, "number": number, "status": "completed", "conclusion": "success"} for number, step in enumerate(steps, 1)], + } + for index, (name, steps) in enumerate(policy.required_steps, 91) + ] + workflow_bytes = (ROOT / policy.workflow).read_bytes() + source = { + "type": "file", + "path": policy.workflow, + "encoding": "base64", + "size": len(workflow_bytes), + "sha": hashlib.sha1(b"blob " + str(len(workflow_bytes)).encode() + b"\0" + workflow_bytes, usedforsecurity=False).hexdigest(), + "content": base64.b64encode(workflow_bytes).decode(), + } + responses: dict[str, Any] = { + "actions/workflows/prepare-release.yml": {"id": 9, "path": policy.workflow, "name": policy.workflow_name, "state": "active"}, + "actions/workflows/9/runs?per_page=100&page=1": {"workflow_runs": [run]}, + "actions/runs/77": run, + "actions/runs/77/attempts/1/jobs?per_page=100&page=1": {"jobs": jobs}, + "actions/runs/77/artifacts?per_page=100&page=1": {"artifacts": [artifact]}, + "actions/artifacts/88": artifact, + f"contents/{policy.workflow}?ref={SHA}": source, + f"git/ref/tags/{TAG}": {"ref": f"refs/tags/{TAG}", "object": {"type": "commit", "sha": SHA}}, + } + calls: list[str] = [] + + def api(endpoint: str, *, repository: str) -> Any: + assert repository == candidate.REPOSITORY + calls.append(endpoint) + assert endpoint in responses, endpoint + return deepcopy(responses[endpoint]) + + def download(repository: str, observed: dict[str, Any], destination: Path, names: tuple[str, ...], **_bounds: int) -> None: + assert repository == candidate.REPOSITORY + assert observed["id"] == 88 + assert names == ("draft-receipt.json", "target.json") + destination.mkdir() + (destination / "target.json").write_bytes(target_bytes) + (destination / "draft-receipt.json").write_bytes(receipt) + + monkeypatch.setattr(release_interfaces, "api", api) + monkeypatch.setattr(release_receipts, "download_artifact", download) + monkeypatch.setattr(candidate, "resolve_revision", lambda *_args: SHA) + monkeypatch.setattr(candidate, "require_release_checkout", lambda *_args: None) + return {"root": root, "owner": owner, "run": run, "artifact": artifact, "jobs": jobs, "calls": calls, "responses": responses} + + +def test_automatic_receipt_retrieval_reuses_original_frozen_identity(receipt_provider: dict[str, Any]) -> None: + fixture = receipt_provider + target = candidate.preparation_target(fixture["root"], fixture["owner"], "reviewed") + original = {path.name: path.read_bytes() for path in target.parent.iterdir()} + assert json.loads(target.read_bytes())["release_id"] == 42 + fixture["calls"].clear() + assert candidate.preparation_target(fixture["root"], fixture["owner"], "reviewed") == target + assert {path.name: path.read_bytes() for path in target.parent.iterdir()} == original + assert not any("actions/workflows/9/runs" in endpoint for endpoint in fixture["calls"]) + fixture["artifact"]["digest"] = "sha256:" + "e" * 64 + with pytest.raises(RuntimeError, match="retrieval failed"): + candidate.preparation_target(fixture["root"], fixture["owner"], "reviewed") + assert {path.name: path.read_bytes() for path in target.parent.iterdir()} == original + + +@pytest.mark.parametrize("failure", ["attempt", "step", "missing", "ambiguous"]) +def test_automatic_receipt_selection_stops_without_output_on_invalid_evidence(receipt_provider: dict[str, Any], failure: str) -> None: + fixture = receipt_provider + if failure == "attempt": + fixture["run"]["run_attempt"] = 2 + elif failure == "step": + fixture["jobs"][1]["steps"][-1]["conclusion"] = "skipped" + else: + history = fixture["responses"]["actions/workflows/9/runs?per_page=100&page=1"]["workflow_runs"] + history[:] = [] if failure == "missing" else [*history, {**fixture["run"], "id": 78}] + with pytest.raises(RuntimeError, match="retrieval failed"): + candidate.preparation_target(fixture["root"], fixture["owner"], "reviewed") + assert not (fixture["owner"] / "final" / SHA / "reviewed/preparation").exists() + + +@pytest.mark.parametrize("failure", ["none", "missing-review", "changed-review", "mutable-release", "dispatch"]) +def test_registry_dispatch_sends_only_the_existing_reviewed_snapshot(tmp_path: Path, monkeypatch: pytest.MonkeyPatch, failure: str) -> None: + root, owner = tmp_path / "checkout", tmp_path / "owner" + root.mkdir() + owner.mkdir() + final = candidate.final_directory(owner, SHA, "reviewed") + final.mkdir(parents=True) + publication = final / "publication-transfer.json" + original = b"original owner publication\r\n" + publication.write_bytes(original) + accepted = final / "acceptance.json" + reviewed = b"original full acceptance\r\n" + if failure != "missing-review": + accepted.write_bytes(reviewed) + monkeypatch.setattr(candidate, "release_tag", lambda _root: TAG) + monkeypatch.setattr(candidate, "record_path", lambda *_args: publication) + monkeypatch.setattr(candidate, "parse_transfer", lambda _payload: SimpleNamespace()) + verified: list[Path] = [] + + def verify(*_args: object) -> Path: + verified.append(accepted) + assert accepted.read_bytes() == reviewed + if failure == "changed-review": + message = "original reviewed native attempt changed" + raise ValueError(message) + return accepted + + def immutable(*_args: object) -> None: + if failure == "mutable-release": + message = "published release is not immutable" + raise ValueError(message) + + calls: list[list[str]] = [] + + def dispatch(executable: str, args: list[str], *, cwd: Path) -> subprocess.CompletedProcess[str]: + assert executable == "gh" + assert cwd == root + assert args[:9] == ["workflow", "run", "publish-crates.yml", "--repo", candidate.REPOSITORY, "--ref", TAG, "-f", f"tag={TAG}"] + assert args[-2:] == ["-f", "acceptance=" + hashlib.sha256(reviewed).hexdigest()] + assert Path(args[10].removeprefix("candidate=@")).read_bytes() == original + calls.append(args) + if failure == "dispatch": + raise subprocess.CalledProcessError(17, [executable, *args]) + return subprocess.CompletedProcess(args, 0, "", "") + + monkeypatch.setattr(candidate, "verify_existing", verify) + monkeypatch.setattr(candidate, "check_published_transfer", immutable) + monkeypatch.setattr(candidate, "run_command", dispatch) + assert_dispatch_outcome(root, owner, verified, failure) + assert len(calls) == (1 if failure in {"none", "dispatch"} else 0) + assert publication.read_bytes() == original + + +def assert_dispatch_outcome(root: Path, owner: Path, verified: list[Path], failure: str) -> None: + """Require each injected boundary failure to retain its intended diagnostic.""" + if failure == "none": + candidate.upload_dispatch(root, owner, "reviewed", TAG) + elif failure == "dispatch": + with pytest.raises(subprocess.CalledProcessError) as stopped: + candidate.upload_dispatch(root, owner, "reviewed", TAG) + assert stopped.value.returncode == 17 + elif failure == "missing-review": + with pytest.raises(FileNotFoundError, match=r"acceptance\.json"): + candidate.upload_dispatch(root, owner, "reviewed", TAG) + assert verified == [] + else: + with pytest.raises(ValueError, match=r"native attempt changed|not immutable"): + candidate.upload_dispatch(root, owner, "reviewed", TAG) + + +def test_downstream_checks_never_create_missing_review(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + root, owner = tmp_path / "checkout", tmp_path / "owner" + root.mkdir() + owner.mkdir() + monkeypatch.setattr(candidate, "resolve_revision", lambda *_args: SHA) + monkeypatch.setattr(candidate, "verify", lambda *_args: pytest.fail("tagging or publication must not create a new review")) + with pytest.raises(FileNotFoundError, match=r"acceptance\.json"): + candidate.verify_existing(root, owner, "reviewed") + assert list(owner.iterdir()) == [] + + +@pytest.mark.parametrize("owner_argument", [[], [""]]) +def test_cli_recovers_the_registered_owner_without_shell_context( + owner_record: tuple[Path, Path], monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], owner_argument: list[str] +) -> None: + root, owner = owner_record + candidate.remember_owner(root, owner) + accepted = owner / "acceptance.json" + accepted.write_bytes(b"reviewed acceptance") + monkeypatch.chdir(root) + monkeypatch.setattr("sys.argv", ["release_candidate.py", "verify", *owner_argument]) + received: list[Path] = [] + + def verify(_root: Path, selected: Path, selection: str) -> Path: + assert _root == root + assert selection == "reviewed" + received.append(selected) + return accepted + + monkeypatch.setattr(candidate, "verify", verify) + candidate.main() + assert received == [owner.resolve()] + assert json.loads(capsys.readouterr().out)["sha256"] == hashlib.sha256(accepted.read_bytes()).hexdigest() + + +def test_conflicting_owner_selection_stops_before_acquisition(owner_record: tuple[Path, Path], monkeypatch: pytest.MonkeyPatch) -> None: + root, owner = owner_record + candidate.remember_owner(root, owner) + other = owner.parent / "new acquisition" + monkeypatch.setattr(candidate, "load_performance_release", lambda *_args: None) + monkeypatch.setattr(candidate, "load_preparation", lambda *_args: None) + monkeypatch.setattr(candidate, "check_release_dependencies", lambda *_args: None) + monkeypatch.setattr(candidate, "measure_release", lambda *_args, **_kwargs: pytest.fail("a known owner conflict must not start acquisition")) + with pytest.raises(ValueError, match="already selects a different original owner"): + candidate.acquire(root, other, "target/new-checkpoint", resume=False, progress=candidate.ProgressReporter(mode="none")) + assert not other.exists() + + +def test_publish_recipe_requires_review_before_reaching_publication(tmp_path: Path) -> None: + root, owner = tmp_path / "checkout", tmp_path / "owner" + root.mkdir() + owner.mkdir() + source = (ROOT / "justfile").read_text(encoding="utf-8") + header = next(line for line in source.splitlines() if line.startswith("release-publish ")) + bootstrap = root / "candidate-bootstrap.py" + bootstrap.write_text( + "import sys\nfrom pathlib import Path\n" + f"sys.path.insert(0, {str(ROOT)!r})\n" + "from tooling import release_candidate as candidate\n" + f"candidate.resolve_revision = lambda *_args: {SHA!r}\n" + 'def unexpected(*_args):\n raise RuntimeError("attempted to create a new review")\n' + "candidate.verify = unexpected\ncandidate.main()\n", + encoding="utf-8", + newline="\n", + ) + marker = root / "published" + overrides = ( + "\n_release-notes-check tag:\n @true\n\n[positional-arguments]\n_release-candidate *args:\n " + f'{shlex.quote(Path(sys.executable).as_posix())} -I {shlex.quote(bootstrap.as_posix())} "$@"\n\n' + + header + + f"\n touch {shlex.quote(marker.as_posix())}\n" + ) + (root / "justfile").write_text("set allow-duplicate-recipes\n" + source + overrides, encoding="utf-8", newline="\n") + result = run_command("just", ["release-publish", TAG, str(owner)], cwd=root, check=False) + assert result.returncode != 0 + assert "acceptance.json" in result.stderr + assert "attempted to create a new review" not in result.stderr + assert not marker.exists() diff --git a/tests/tooling/test_release_flow.py b/tests/tooling/test_release_flow.py new file mode 100644 index 0000000..07a893b --- /dev/null +++ b/tests/tooling/test_release_flow.py @@ -0,0 +1,241 @@ +"""Exercise release command transport and the installed retained-draft handoff.""" + +import json +import os +import runpy +import shlex +import subprocess +import sys +from copy import deepcopy +from pathlib import Path +from typing import Any + +import pytest +import yaml +from research_repo_tools import release_assets, release_drafts, release_evidence, release_validation +from research_repo_tools.just_inspect import dry_run +from research_repo_tools.process import run_command + +from tests.tooling.test_release_evidence import COMMIT, POLICY, REPOSITORY, EvidenceProvider + +ROOT = Path(__file__).resolve().parents[2] +TAG = "v0.5.42" +WORKFLOW = ROOT / ".github/workflows/prepare-release.yml" + + +@pytest.mark.parametrize("outcome", ["success", "automatic", "dispatch-failure", "missing", "wrong-tag", "malformed"]) +def test_benchmark_recipe_forwards_one_tag_and_independent_transfer(tmp_path: Path, outcome: str) -> None: + transfer = tmp_path / "owner's transfer $literal.json" + data = {"schema": 1, "target": {"repository": REPOSITORY, "tag": TAG, "expected_title": TAG}} + if outcome == "wrong-tag": + data["target"]["tag"] = "v9.9.9" + transfer.write_text("invalid" if outcome == "malformed" else json.dumps(data), encoding="utf-8", newline="\n") + calls = tmp_path / "calls.json" + provider = tmp_path / "provider.py" + provider.write_text( + "import json, os, pathlib, sys\n" + 'pathlib.Path(os.environ["FIXTURE_CALLS"]).write_text(json.dumps(sys.argv[1:]), encoding="utf-8")\n' + 'sys.exit(int(os.environ["FIXTURE_STATUS"]))\n', + encoding="utf-8", + newline="\n", + ) + script = tmp_path / "dispatch.sh" + script.write_text( + f'gh() {{ {shlex.quote(Path(sys.executable).as_posix())} {shlex.quote(provider.as_posix())} "$@"; }}\n' + 'just() { [[ "$*" == "--quiet _release-candidate record transfer" ]] || return 90; ' + '[[ "$FIXTURE_AUTOMATIC" == true ]] || { echo "no original Step 1 owner selection" >&2; return 2; }; printf "%s\\n" "$FIXTURE_TRANSFER"; }\n' + + dry_run(ROOT, "release-benchmarks", [TAG]).stderr, + encoding="utf-8", + newline="\n", + ) + env = {key: value for key, value in os.environ.items() if key not in {"BASH_ENV", "SHELLOPTS", "BASHOPTS", "CANDIDATE_TRANSFER_FILE"}} + env.update(FIXTURE_CALLS=calls.as_posix(), FIXTURE_STATUS="17" if outcome == "dispatch-failure" else "0") + env.update(FIXTURE_AUTOMATIC=str(outcome == "automatic").lower(), FIXTURE_TRANSFER=transfer.as_posix()) + if outcome not in {"missing", "automatic"}: + env["CANDIDATE_TRANSFER_FILE"] = transfer.as_posix() + result = run_command("bash", ["--noprofile", "--norc", "-eu", script.as_posix()], cwd=tmp_path, env=env, check=False) + if outcome in {"success", "automatic", "dispatch-failure"}: + assert result.returncode == (17 if outcome == "dispatch-failure" else 0), result.stderr + assert json.loads(calls.read_text(encoding="utf-8")) == [ + "workflow", + "run", + "release-benchmarks.yml", + "--repo", + REPOSITORY, + "--ref", + TAG, + "-f", + f"tag={TAG}", + "-F", + f"candidate=@{transfer.as_posix()}", + ] + else: + assert result.returncode != 0 + assert not calls.exists() + if outcome == "missing": + assert "no original Step 1 owner selection" in result.stderr + + +def test_benchmark_dispatch_requires_an_argument() -> None: + result = run_command("just", ["release-benchmarks"], cwd=ROOT, check=False) + assert result.returncode == 1 + assert "got 0 positional arguments but takes 1" in result.stderr + + +@pytest.mark.parametrize("tag", ["", "main", "v01.2.3", "v1.2.3; touch injected"]) +def test_benchmark_dispatch_rejects_noncanonical_or_shell_tags(tmp_path: Path, tag: str) -> None: + script = tmp_path / "invalid-tag.sh" + script.write_text("gh() { exit 91; }\n" + dry_run(ROOT, "release-benchmarks", [tag]).stderr, encoding="utf-8", newline="\n") + result = run_command("bash", ["--noprofile", "--norc", "-eu", script.as_posix()], cwd=tmp_path, check=False) + assert result.returncode == 2 + assert "A canonical stable tag is required" in result.stderr + assert not (tmp_path / "injected").exists() + + +def draft_provider(state: dict[str, Any], args: list[str]) -> bytes: + """Serve release observations while retaining requests and exact notes.""" + assert args[0] == "api" + if "POST" in args: + request = json.loads(Path(args[-1]).read_bytes()) + state["posts"].append(request) + state["release"] = {**request, "id": 42, "url": f"https://api.github.com/repos/{REPOSITORY}/releases/42", "immutable": False, "assets": []} + return json.dumps(state["release"]).encode() + endpoint = args[-1] + if endpoint.endswith("releases?per_page=100"): + return json.dumps([[state["release"]] if state["release"] else []]).encode() + if endpoint.endswith("releases/42"): + return json.dumps(state["release"]).encode() + if endpoint.endswith("releases/42/assets?per_page=100"): + return b"[[]]" + if endpoint.endswith(f"git/ref/tags/{TAG}"): + return json.dumps({"ref": f"refs/tags/{TAG}", "object": {"type": "tag", "sha": "b" * 40}}).encode() + assert endpoint.endswith("git/tags/" + "b" * 40), args + return json.dumps({"sha": "b" * 40, "object": {"type": "commit", "sha": state.get("remote", COMMIT)}}).encode() + + +@pytest.fixture +def draft_handoff(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> dict[str, Any]: + """Keep the real evidence verifier, draft API, receipt and workflow writer.""" + provider = EvidenceProvider(tmp_path) + provider.install(monkeypatch) + selection = release_evidence.verify_release_evidence(POLICY, "0.5.42", COMMIT, lambda endpoint: provider.api(endpoint, repository=REPOSITORY)) + notes = b"Generated notes with `code`, $literal and CRLF\r\n" + current = { + "id": 77, + "workflow_id": 9, + "run_attempt": 1, + "event": "push", + "head_branch": TAG, + "head_sha": COMMIT, + "path": ".github/workflows/prepare-release.yml", + } + artifact = { + "id": 88, + "expired": False, + "name": "draft-handoff-77-1", + "digest": "sha256:" + "d" * 64, + "workflow_run": {"id": 77, "head_sha": COMMIT}, + } + state: dict[str, Any] = {"release": None, "posts": [], "current": current, "artifact": artifact, "history": [deepcopy(current)], "selection": selection} + for key, value in { + "GITHUB_REPOSITORY": REPOSITORY, + "GITHUB_RUN_ID": "77", + "GITHUB_RUN_ATTEMPT": "1", + "GITHUB_EVENT_NAME": "push", + "GITHUB_REF": f"refs/tags/{TAG}", + "GITHUB_SHA": COMMIT, + "RELEASE_TAG": TAG, + "RELEASE_COMMIT": COMMIT, + "HANDOFF_ID": "88", + "HANDOFF_SHA256": "d" * 64, + "VALIDATED_ATTEMPT": "1", + }.items(): + monkeypatch.setenv(key, value) + monkeypatch.chdir(tmp_path) + + def api(endpoint: str, *, repository: str) -> Any: + assert repository == REPOSITORY + if endpoint == "actions/artifacts/88": + return deepcopy(artifact) + if endpoint == "actions/runs/77": + return deepcopy(current) + if endpoint == "actions/workflows/9/runs?per_page=100&page=1": + return {"workflow_runs": deepcopy(state["history"])} + return provider.api(endpoint, repository=repository) + + def download(repository: str, _artifact: object, destination: Path, names: tuple[str, ...]) -> None: + assert repository == REPOSITORY + assert names == ("evidence.toml", "selection.json", "notes.md") + destination.mkdir(exist_ok=True) + (destination / "evidence.toml").write_bytes((ROOT / "tooling/release-evidence.toml").read_bytes()) + (destination / "selection.json").write_bytes(selection) + (destination / "notes.md").write_bytes(notes) + + def gh(_root: Path, args: list[str]) -> bytes: + return draft_provider(state, args) + + monkeypatch.setattr(release_validation, "api", api) + monkeypatch.setattr(release_drafts, "api", api) + monkeypatch.setattr(release_validation, "download_artifact", download) + monkeypatch.setattr(release_assets, "_gh", gh) + monkeypatch.setattr(release_drafts, "_gh", gh) + # Step 2 must never launch builds, tests or acquisition, even on retries. + monkeypatch.setattr(subprocess, "run", lambda *_args, **_kwargs: pytest.fail("draft inspection must launch no build/test/acquisition command")) + workflow = yaml.safe_load(WORKFLOW.read_bytes()) + source = next(step["run"] for step in workflow["jobs"]["prepare"]["steps"] if "run" in step) + script = tmp_path / "writer.py" + script.write_text(source.split("<<'PY'\n", 1)[1].rsplit("\nPY", 1)[0], encoding="utf-8", newline="\n") + return {**state, "state": state, "provider": provider, "script": script} + + +@pytest.mark.parametrize("suffix", ["", "@refs/tags/" + TAG, "@" + TAG]) +def test_actual_writer_creates_once_and_retains_exact_draft(draft_handoff: dict[str, Any], tmp_path: Path, suffix: str) -> None: + draft_handoff["current"]["path"] += suffix + script = draft_handoff["script"] + runpy.run_path(str(script), run_name="__main__") + original = (tmp_path / "draft-receipt.json").read_bytes() + runpy.run_path(str(script), run_name="__main__") + assert len(draft_handoff["posts"]) == 1 + assert (tmp_path / "draft-receipt.json").read_bytes() == original + target = release_assets.parse_release_target((tmp_path / "target.json").read_bytes()) + assert (target.tag, target.commit, target.release_id, target.expected_title) == (TAG, COMMIT, 42, TAG) + + +@pytest.mark.parametrize("failure", ["ref", "commit", "attempt", "artifact", "digest", "expired", "history", "workflow", "native"]) +def test_writer_rejects_changed_handoff_without_creating_a_draft( + draft_handoff: dict[str, Any], tmp_path: Path, monkeypatch: pytest.MonkeyPatch, failure: str +) -> None: + if failure in {"ref", "commit", "attempt"}: + monkeypatch.setenv({"ref": "GITHUB_REF", "commit": "GITHUB_SHA", "attempt": "GITHUB_RUN_ATTEMPT"}[failure], "changed") + if failure == "attempt": + monkeypatch.setenv("GITHUB_RUN_ATTEMPT", "2") + elif failure in {"artifact", "digest", "expired"}: + key, value = {"artifact": ("id", 89), "digest": ("digest", "sha256:" + "e" * 64), "expired": ("expired", True)}[failure] + draft_handoff["artifact"][key] = value + elif failure == "history": + draft_handoff["history"].append({**draft_handoff["current"], "id": 76}) + elif failure == "workflow": + draft_handoff["current"]["path"] = ".github/workflows/other.yml" + else: + draft_handoff["provider"].jobs[0]["conclusion"] = "failure" + diagnostic = "original exact tag push" if failure in {"ref", "commit", "attempt"} else "handoff|preparation intent|did not succeed" + with pytest.raises(ValueError, match=diagnostic): + runpy.run_path(str(draft_handoff["script"]), run_name="__main__") + assert draft_handoff["posts"] == [] + assert not (tmp_path / "draft-receipt.json").exists() + + +def test_draft_workflow_keeps_the_writer_isolated_and_recovery_explicit() -> None: + workflow = yaml.safe_load(WORKFLOW.read_bytes()) + assert workflow[True] == {"push": {"tags": ["v*"]}} + assert workflow["concurrency"] == {"group": "release-draft-${{ github.repository }}-${{ github.ref_name }}", "cancel-in-progress": False} + writer = workflow["jobs"]["prepare"] + assert writer["needs"] == "verify" + assert writer["permissions"] == {"actions": "read", "contents": "write"} + assert all(not step.get("uses", "").startswith("./") and "checkout@" not in step.get("uses", "") for step in writer["steps"]) + script = writer["steps"][1]["run"] + assert 'test "$GITHUB_RUN_ATTEMPT" = 1' in script + assert "--no-config --no-project --isolated --no-cache" in script + assert writer["steps"][-1]["if"] == "always()" + assert "draft-receipt.json" in writer["steps"][-1]["with"]["path"] + assert all(step.get("with", {}).get("enable-cache", False) is False for step in writer["steps"]) diff --git a/tests/tooling/test_release_publishing.py b/tests/tooling/test_release_publishing.py index aa4074c..26dbd16 100644 --- a/tests/tooling/test_release_publishing.py +++ b/tests/tooling/test_release_publishing.py @@ -225,7 +225,7 @@ def test_workflow_requires_approval_revalidation_and_absence_before_oidc() -> No assert steps[7]["if"] == "${{ !cancelled() && steps.auth.outcome == 'success' }}" assert steps[7]["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 + commands = dry_run(REPO_ROOT, "publish-check").stderr assert commands.count("--package la-stack --registry crates-io --dry-run") == 2 assert "--features exact" in commands assert "--allow-dirty" not in commands @@ -269,7 +269,7 @@ def evidence_runner(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: source = (REPO_ROOT / "justfile").read_text(encoding="utf-8") runtime = ( '[script("uvx", "--python", "3.15", "--from", "research-repo-tools @ ' - 'git+https://github.com/acgetchell/research-repo-tools@055353c630604b1ae3438c1eaf534ba85e067a7e", "python", "-I")]' + 'git+https://github.com/acgetchell/research-repo-tools@cd8594f658b5bf3ade063a685834565cc62e0cb3", "python", "-I")]' ) assert source.count(runtime) == 3 # Run the exact recipe body/argument transport with the locked test runtime. @@ -528,7 +528,7 @@ def test_main_ci_seals_only_complete_native_and_cargo_validation() -> None: assert jobs["build"]["steps"][-1] == {"name": "Run CI checks", "run": "just ci"} package = jobs["release-package"] assert package["steps"][1]["with"]["cache"] == "false" - assert package["steps"][-1] == {"name": "Verify default and exact Cargo packages", "run": "just release-package"} + assert package["steps"][-1] == {"name": "Verify default and exact Cargo packages", "run": "just publish-check"} evidence = jobs["release-evidence"] assert evidence["needs"] == ["build", "release-package"] assert evidence["if"] == "github.event_name == 'push' && github.ref == 'refs/heads/main' && github.repository == 'acgetchell/la-stack'" @@ -543,7 +543,7 @@ def test_main_ci_seals_only_complete_native_and_cargo_validation() -> None: "--python", "3.15", "--from", - "research-repo-tools @ git+https://github.com/acgetchell/research-repo-tools@055353c630604b1ae3438c1eaf534ba85e067a7e", + "research-repo-tools @ git+https://github.com/acgetchell/research-repo-tools@cd8594f658b5bf3ade063a685834565cc62e0cb3", "python", "-I", "-", @@ -593,7 +593,7 @@ def create(arguments: list[str]) -> int: def test_native_packaging_failure_stops_before_further_validation(tmp_path: Path) -> None: - script = dry_run(REPO_ROOT, "release-package").stderr + script = dry_run(REPO_ROOT, "publish-check").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" diff --git a/tests/tooling/test_toolchain_integration.py b/tests/tooling/test_toolchain_integration.py index 4e28d2d..d4b6bea 100644 --- a/tests/tooling/test_toolchain_integration.py +++ b/tests/tooling/test_toolchain_integration.py @@ -23,7 +23,7 @@ import subprocess REPO_ROOT = Path(__file__).resolve().parents[2] -TOOLING_REVISION = "055353c630604b1ae3438c1eaf534ba85e067a7e" +TOOLING_REVISION = "cd8594f658b5bf3ade063a685834565cc62e0cb3" TOOLING_REPOSITORY = "https://github.com/acgetchell/research-repo-tools" TOOLING_REQUIREMENT = f"research-repo-tools @ git+{TOOLING_REPOSITORY}@{TOOLING_REVISION}" CHECKED = ["run", "--locked", "--no-sync", "--no-python-downloads", "research-repo-tools", "toolchain", "run", "--"] diff --git a/tests/tooling/test_validation_graph.py b/tests/tooling/test_validation_graph.py new file mode 100644 index 0000000..8728a91 --- /dev/null +++ b/tests/tooling/test_validation_graph.py @@ -0,0 +1,132 @@ +"""Preserve every native validator while overlapping only independent lanes.""" + +import json +import os +import shlex +import sys +from pathlib import Path +from typing import Any + +import pytest +import yaml +from research_repo_tools.just_inspect import inspect_justfile +from research_repo_tools.process import run_command + +ROOT = Path(__file__).resolve().parents[2] +POLICY = { + "action-lint", + "workflow-allowlist-check", + "zizmor", + "markdown-check", + "spell-check", + "_version-check", + "changelog-check", + "toml-parse-check", + "toml-fmt-check", + "toml-lint", + "yaml-fmt-check", + "yaml-lint", + "citation-check", + "validate-json", + "justfile-fmt-check", + "python-check", + "cargo-lock-check", + "fmt-check", + "semgrep", + "semgrep-test", + "unused-deps", + "shell-check", +} +RUST = {"clippy-all-targets", "doc-check", "test-rust-ci", "test-doc", "test-doc-exact", "bench-compile", "test-bench-exact", "examples"} + + +def reachable(recipes: dict[str, dict[str, Any]], name: str) -> set[str]: + """Find prerequisite owners from native Just metadata.""" + return {name}.union(*(reachable(recipes, dependency["recipe"]) for dependency in recipes[name]["dependencies"])) + + +@pytest.fixture +def graph(tmp_path: Path) -> tuple[Path, set[str]]: + """Keep the real dependency graph, replacing only validator command bodies.""" + recipes = inspect_justfile(ROOT).recipes + assert {item["recipe"] for item in recipes["_ci-policy"]["dependencies"]} == POLICY + assert {item["recipe"] for item in recipes["_ci-rust"]["dependencies"]} == RUST + assert recipes["_ci-execution"]["attributes"] == ["parallel", "private"] + assert [item["recipe"] for item in recipes["_ci-execution"]["dependencies"]] == ["_ci-rust", "test-python"] + selected = reachable(recipes, "ci") - {"ci", "_ci-policy", "_ci-rust", "_ci-execution"} + marker = tmp_path / "step.py" + marker.write_text( + "import json, os, pathlib, sys, time\n" + "name = sys.argv[1]\nroot = pathlib.Path.cwd()\n" + 'done = root / (name + ".done")\n' + 'assert not done.exists(), "validator ran twice: " + name\n' + 'if name in {"clippy-all-targets", "test-python"}:\n' + ' assert (root / "python-sync.done").exists(), "environment is not synchronized"\n' + ' (root / (name + ".start")).touch()\n' + ' if os.environ["FIXTURE_PARALLEL"] == "true":\n' + ' peer = "test-python" if name == "clippy-all-targets" else "clippy-all-targets"\n' + " deadline = time.monotonic() + 10\n" + ' while not (root / (peer + ".start")).exists():\n' + ' assert time.monotonic() < deadline, "independent lane never started"\n' + " time.sleep(0.01)\n" + 'if os.environ["FIXTURE_FAIL"] == name:\n' + ' print("injected validator failure: " + name, file=sys.stderr)\n sys.exit(19)\n' + 'done.write_text(json.dumps({"name": name}), encoding="utf-8")\n', + encoding="utf-8", + newline="\n", + ) + overrides = [] + for name in sorted(selected): + recipe = recipes[name] + parameters = " *args" if name in {"bench-exact", "zizmor"} else "" + assert not recipe["parameters"] or name in {"bench-exact", "zizmor"} + dependencies = " ".join( + "(" + item["recipe"] + " " + " ".join(json.dumps(arg) for arg in item["arguments"]) + ")" if item["arguments"] else item["recipe"] + for item in recipe["dependencies"] + ) + overrides.append(f"{name}{parameters}: {dependencies}\n {shlex.quote(Path(sys.executable).as_posix())} {shlex.quote(marker.as_posix())} {name}\n") + source = (ROOT / "justfile").read_text(encoding="utf-8") + (tmp_path / "justfile").write_text("set allow-duplicate-recipes\n" + source + "\n" + "\n".join(overrides), encoding="utf-8", newline="\n") + return tmp_path, selected + + +@pytest.mark.parametrize("parallel", [False, True], ids=["serial", "parallel"]) +@pytest.mark.parametrize("failure", ["none", "python-check", "test-python", "test-rust-ci"]) +def test_real_graph_runs_each_validator_once_and_propagates_failures(graph: tuple[Path, set[str]], parallel: bool, failure: str) -> None: + root, selected = graph + env = {**os.environ, "FIXTURE_PARALLEL": str(parallel).lower(), "FIXTURE_FAIL": failure} + result = run_command("just", ["--jobs", "2" if parallel else "1", "ci"], cwd=root, env=env, check=False, timeout=45) + completed = {path.name.removesuffix(".done") for path in root.glob("*.done")} + if failure == "none": + assert result.returncode == 0, result.stderr + assert completed == selected + else: + assert result.returncode != 0 + assert f"injected validator failure: {failure}" in result.stderr + assert failure not in completed + if failure == "python-check": + assert not list(root.glob("*.start")) + assert "python-sync" in completed + + +def test_managed_cache_contains_compilation_only_and_release_jobs_disable_it() -> None: + action = yaml.safe_load((ROOT / ".github/actions/setup-tools/action.yml").read_bytes()) + steps = action["runs"]["steps"] + cache = next(step for step in steps if step.get("uses", "").startswith("actions/cache@")) + assert cache["if"] == "inputs.cache == 'true'" + assert cache["with"]["path"] == "${{ runner.temp }}/rrt-cargo-build" + assert "runner.os" in cache["with"]["key"] + assert "runner.arch" in cache["with"]["key"] + assert all(name in cache["with"]["key"] for name in ("rust-toolchain.toml", "pyproject.toml", "uv.lock", "Cargo.lock")) + assert "restore-keys" not in cache["with"] + setup = next(step for step in steps if step.get("run", "").endswith("research-repo-tools setup")) + assert setup["env"] == {"RESEARCH_REPO_TOOLS_HOME": "${{ runner.temp }}/rrt", "RESEARCH_REPO_TOOLS_CARGO_BUILD_HOME": cache["with"]["path"]} + for filename in ("ci.yml", "release-benchmarks.yml", "publish-crates.yml", "prepare-release.yml"): + workflow = yaml.safe_load((ROOT / ".github/workflows" / filename).read_bytes()) + for name, job in workflow["jobs"].items(): + if filename == "ci.yml" and name == "build": + continue + for step in job["steps"]: + if step.get("uses") == "./.github/actions/setup-tools": + assert str(step["with"]["cache"]).lower() == "false" + assert not step.get("uses", "").startswith("actions/cache@") diff --git a/tooling/README.md b/tooling/README.md index 6a2d7b3..1939148 100644 --- a/tooling/README.md +++ b/tooling/README.md @@ -25,6 +25,14 @@ slowest test durations are reported; assertions and test selection are unchanged repository validation. Native Linux, macOS and Windows CI is required for adoption; local macOS results do not establish the other platforms. +After shared setup and fast policy checks, `just ci` overlaps Python consumer +tests with a single sequential Cargo lane. `just --jobs 1 ci` preserves serial +execution. No measurement recipes join either lane. The shared managed Cargo +build cache retains compilation separately from exact-version installations; +ordinary CI caches only that compilation directory, with exact OS/architecture +and tool/lock keys. Release jobs disable caches. See +[validation integration](../docs/dev/release-flow-integration.md). + For subprocess fixtures, run Bash and Python source from files, and pass paths and payloads as arguments or environment data. Nested `-c` commands add Windows argument parsing boundaries that POSIX shell quoting does not protect. Supply an @@ -59,7 +67,7 @@ Other platforms resolve Semgrep 1.184.0 or newer within the shared range. ## Shared capability adoption This development branch tests upstream commit -`055353c630604b1ae3438c1eaf534ba85e067a7e` through an exact uv Git source override. +`cd8594f658b5bf3ade063a685834565cc62e0cb3` through an exact uv Git source override. The package metadata still says 0.2.6; integration checks verify the manifest, lock and installed commit, including the isolated `uvx --python 3.15` runtimes. Advance the full commit deliberately after upstream changes; do not float on @@ -89,7 +97,7 @@ come from their registries for release. The file is retained in source exports and both benchmark harness declarations, so policy changes invalidate evidence. The capability inventory below uses the pinned CLI and -its [exact-commit contracts](https://github.com/acgetchell/research-repo-tools/blob/055353c630604b1ae3438c1eaf534ba85e067a7e/README.md#common-workflows). +its [exact-commit contracts](https://github.com/acgetchell/research-repo-tools/blob/cd8594f658b5bf3ade063a685834565cc62e0cb3/README.md#common-workflows). The shared updater installs and verifies candidate Cargo tools before publishing new pins; no local updater implementation is needed. Native Rust algorithms, assertions, benchmark selection and release decisions @@ -286,7 +294,7 @@ explicit dates remain supported. Generation never advances citation metadata. The publishing declaration selects only `la-stack`, requires native platform and scientific checks 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 +use the shared release contract; `publish-check` owns native default/exact Cargo dry-runs. Main CI signs those packaging and native validation identities; `release-evidence ` binds the original acquisition, precise publisher receipt, final clean commit and signed native acceptance. Named review records @@ -295,7 +303,11 @@ are immutable; selecting a later native attempt requires an explicit new name. `release-assets.toml` owns the draft-target policy. Evidence recipes use the pinned installed runtime outside the observed checkout. The [release guide](../docs/RELEASING.md#reusable-ci-evidence) owns the independent owner selection and frozen approval contract. -`release-stage` freezes the draft ID before upload; `release-publication` adds +Tag-triggered `prepare-release.yml` reuses signed native evidence and generated +notes through the installed draft API. Its isolated writer retains the original +journal and captured target. `release-stage` retrieves that original target through +the installed tag-aware receipt verifier and freezes its ID before upload; +`release-publication` adds canonical archive ID/hash assertions. Two maintainer dispatches use those independent records. Write-scope hosted jobs execute no checkout code; read-only verification authenticates full acceptance with an owner-reviewed @@ -307,6 +319,15 @@ the original immutable release archive and asset selections. `tests/tooling/test_release_publishing.py` checks consumer policy and upload barriers without registry credentials. The release guide owns retention, complete-rerun recovery and the distinction between reused checks and Cargo repackaging. +`tests/tooling/test_release_flow.py` exercises the actual draft writer with the +installed verifier and draft API, plus canonical `release-benchmarks TAG` +forwarding with automatic original-owner lookup or explicit `CANDIDATE_TRANSFER_FILE`. +Step 1 retains the immutable external owner selection; routine Step 2 recipes +need no `OWNER` or `RELEASE_COMMIT` shell variables. `release-upload` dispatches +the existing reviewed publication selection without approving deployment. +`release-version-check` +requires final metadata; ordinary CI retains a private nonfinal metadata check. +There are no `docs-version-check` or `release-package` recipe aliases. `tests/tooling/test_toolchain_integration.py` owns the package-identity check: manifest source, exact Git lock and installed commit for this rehearsal. diff --git a/tooling/draft-receipt.toml b/tooling/draft-receipt.toml new file mode 100644 index 0000000..fa093ce --- /dev/null +++ b/tooling/draft-receipt.toml @@ -0,0 +1,15 @@ +schema = 1 +repository = "acgetchell/la-stack" +workflow = ".github/workflows/prepare-release.yml" +workflow-name = "Prepare GitHub release" +artifact-prefix = "draft-receipt" + +[[jobs]] +name = "verify" +steps = [ "Verify tagged source and freeze native evidence and notes", "Retain inert verified handoff" ] +runner-labels = [ "ubuntu-latest" ] + +[[jobs]] +name = "prepare" +steps = [ "Authenticate handoff and prepare the unpublished draft", "Retain journal even after an unconfirmed creation" ] +runner-labels = [ "ubuntu-latest" ] diff --git a/tooling/release_candidate.py b/tooling/release_candidate.py index cef126a..aaf3590 100644 --- a/tooling/release_candidate.py +++ b/tooling/release_candidate.py @@ -30,19 +30,21 @@ from research_repo_tools.measurement import load_measurement, resolve_revision from research_repo_tools.performance_progress import ProgressReporter from research_repo_tools.performance_release import load_performance_release, load_preparation, measure_release, serialize_preparation +from research_repo_tools.process import run_command from research_repo_tools.publication import publish_publication from research_repo_tools.release_assets import ( ReleaseTarget, download_release_asset, parse_release_target, - preflight_release_target, publish_release_asset, + require_release_checkout, revalidate_release_target, serialize_release_target, ) from research_repo_tools.release_interfaces import load_evidence_policy +from research_repo_tools.release_metadata import read_package_info from research_repo_tools.release_publishing import ReviewedRelease, check_reviewed_release -from research_repo_tools.release_validation import api +from research_repo_tools.release_validation import api, parse_release_json from research_repo_tools.scheduled_runs import ScheduledRun from research_repo_tools.suite_runs import parse_suite_run @@ -51,10 +53,26 @@ ROLES = ("policy", "publication", "acceptance", "archive") +def release_tag(root: Path) -> str: + """Derive the canonical release identity from the observed package.""" + tag = "v" + read_package_info(root).version + if re.fullmatch(r"v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)", tag) is None: + message = "release context requires a canonical stable package version" + raise ValueError(message) + return tag + + +def selection_directory(root: Path) -> Path: + """Address one original selection by checkout and tag, never by latest time.""" + checkout = hashlib.sha256(str(root.resolve()).encode("utf-8")).hexdigest() + directory = Path.home() / ".local/state/la-stack/releases" / checkout / release_tag(root) + return owner_directory(root, "", directory) + + def owner_directory(root: Path, checkpoint: str, selected: Path | None = None) -> Path: """Keep the operator's immutable record outside candidate-controlled files.""" identity = hashlib.sha256(str((root / checkpoint).resolve()).encode("utf-8")).hexdigest() - directory = (selected or Path.home() / ".local/state/la-stack/acquisitions" / identity).expanduser().resolve() + directory = (selected if selected is not None else Path.home() / ".local/state/la-stack/acquisitions" / release_tag(root) / identity).expanduser().resolve() if directory.is_relative_to(root) or root.is_relative_to(directory): message = "acquisition owner directory must be separate from the checkout" raise ValueError(message) @@ -78,6 +96,67 @@ def unchanged() -> None: replace_many(updates, verify=unchanged) +def remember_owner(root: Path, owner: Path) -> None: + """Register only original Step 1 assertions, before report promotion.""" + policy = parse_candidate_policy((owner / "policy.json").read_bytes()) + if policy.tag != release_tag(root): + message = "acquisition tag differs from the package release context" + raise ValueError(message) + directory = selection_directory(root) + directory.mkdir(mode=0o700, parents=True, exist_ok=True) + record = { + "schema": 1, + "repository": REPOSITORY, + "checkout": str(root.resolve()), + "tag": policy.tag, + "owner": str(owner.resolve()), + "acquisition": (owner / "acquisition.sha256").read_text(encoding="ascii").strip(), + "publication": (owner / "publication.sha256").read_text(encoding="ascii").strip(), + } + freeze(directory, {"owner.json": json.dumps(record, sort_keys=True).encode("utf-8") + b"\n"}) + + +def selected_owner(root: Path, explicit: str = "") -> Path: + """Resolve the original external selection; explicit recovery never replaces it.""" + if explicit: + return owner_directory(root, "", Path(explicit)) + path = selection_directory(root) / "owner.json" + try: + data = parse_release_json(path.read_bytes()) + except FileNotFoundError as error: + message = "no original Step 1 owner selection; restore its external owner.json or pass the original owner directory explicitly" + raise ValueError(message) from error + fields = {"schema", "repository", "checkout", "tag", "owner", "acquisition", "publication"} + if ( + not isinstance(data, dict) + or set(data) != fields + or type(data["schema"]) is not int + or data["schema"] != 1 + or (data["repository"], data["checkout"], data["tag"]) != (REPOSITORY, str(root.resolve()), release_tag(root)) + or not isinstance(data["owner"], str) + or not Path(data["owner"]).is_absolute() + ): + message = "original owner selection differs from this checkout and release" + raise ValueError(message) + owner = owner_directory(root, "", Path(data["owner"])) + for field in ("acquisition", "publication"): + digest = data[field] + if not isinstance(digest, str) or re.fullmatch(r"[0-9a-f]{64}", digest) is None: + message = "owner selection requires original SHA-256 assertions" + raise ValueError(message) + if (owner / f"{field}.sha256").read_text(encoding="ascii").strip() != digest: + message = "original owner assertions changed; restore the retained selection" + raise ValueError(message) + policy = parse_candidate_policy((owner / "policy.json").read_bytes()) + if policy.tag != data["tag"] or acquisition_assertion_digest(policy) != data["acquisition"]: + message = "original owner policy differs from the retained selection" + raise ValueError(message) + if hashlib.sha256((owner / "publication.json").read_bytes()).hexdigest() != data["publication"]: + message = "original publication receipt differs from the retained selection" + raise ValueError(message) + return owner + + def capture(root: Path, owner: Path, *, retained_scratch: bool = False) -> dict[str, str]: """Finish the trusted owner's receipt from its already retained original bytes.""" settings = load_performance_release(root, SETTINGS) @@ -127,6 +206,9 @@ def acquire(root: Path, owner: Path, checkpoint: str, *, resume: bool, progress: settings = load_performance_release(root, SETTINGS) preparation = load_preparation(root, settings) check_release_dependencies(root) + if (selection_directory(root) / "owner.json").exists() and selected_owner(root) != owner.resolve(): + message = "this checkout/tag already selects a different original owner; use that record or explicitly reconcile the release before acquisition" + raise ValueError(message) if resume: if not owner.is_dir(): message = "resume requires the original acquisition owner record" @@ -147,6 +229,7 @@ def acquire(root: Path, owner: Path, checkpoint: str, *, resume: bool, progress: def before_promotion() -> None: freeze(owner, {"run.json": (root / settings.payload).read_bytes(), "evidence.json": (root / settings.manifest).read_bytes()}) capture(root, owner) + remember_owner(root, owner) measure_release( root, @@ -219,6 +302,16 @@ def verify(root: Path, owner: Path, selection: str = "reviewed") -> Path: return accepted +def verify_existing(root: Path, owner: Path, selection: str) -> Path: + """Revalidate an already reviewed commit without silently creating a selection.""" + accepted = final_directory(owner, resolve_revision(root, "HEAD"), selection) / "acceptance.json" + expected = accepted.read_bytes() + if verify(root, owner, selection).read_bytes() != expected: + message = "reviewed acceptance changed; repeat explicit release-evidence review" + raise ValueError(message) + return accepted + + def archive(owner: Path, commit: str, selection: str = "reviewed") -> Path: """Derive an immutable release archive without acquisition or native work.""" final = final_directory(owner, commit, selection) @@ -301,21 +394,46 @@ def digest(value: object) -> str: return Transfer(target, digest(data["acquisition"]), digest(data["publication"]), assets) -def stage(root: Path, owner: Path, selection: str) -> Path: +def preparation_target(root: Path, owner: Path, selection: str) -> Path: + """Retrieve the original authenticated target through the installed supplier.""" + commit = resolve_revision(root, "HEAD") + require_release_checkout(root, commit) + output = final_directory(owner, commit, selection) / "preparation" + status = shared_main( + [ + "--root", + str(root), + "release", + "assets", + "retrieve", + "tooling/draft-receipt.toml", + "--commit", + commit, + "--tag", + release_tag(root), + "--output", + str(output), + ] + ) + if status: + raise RuntimeError(f"original preparation retrieval failed with exit {status}") + return output / "target.json" + + +def stage(root: Path, owner: Path, selection: str, prepared_target: Path | None = None) -> Path: """Upload inert original records, retaining dispatch assertions independently.""" - accepted = verify(root, owner, selection) + accepted = verify_existing(root, owner, selection) commit = resolve_revision(root, "HEAD") final = final_directory(owner, commit, selection) policy = parse_candidate_policy((final / "policy.json").read_bytes()) artifact = archive(owner, commit, selection) target_path = final / "target.json" - if not target_path.exists(): - target = preflight_release_target(root, REPOSITORY, policy.tag, commit, expected_title=policy.tag, checkout=root) - freeze(final, {"target.json": serialize_release_target(target)}) - target = parse_release_target(target_path.read_bytes()) - if (target.repository, target.tag, target.commit) != (REPOSITORY, policy.tag, commit): + target = parse_release_target((prepared_target or preparation_target(root, owner, selection)).read_bytes()) + if (target.repository, target.tag, target.commit, target.expected_title) != (REPOSITORY, policy.tag, commit, policy.tag): message = "staging target differs from the accepted candidate" raise ValueError(message) + freeze(final, {"target.json": serialize_release_target(target)}) + target = parse_release_target(target_path.read_bytes()) revalidate_release_target(root, target, checkout=root) inputs = {"policy": final / "policy.json", "publication": owner / "publication.json", "acceptance": accepted, "archive": artifact} transport = final / "transport" @@ -373,6 +491,64 @@ def seal(root: Path, owner: Path, selection: str) -> Path: return final / "publication-transfer.json" +def record_path(root: Path, owner: Path, selection: str, role: str) -> Path: + """Locate an already retained transfer for this exact clean release commit.""" + commit = resolve_revision(root, "HEAD") + require_release_checkout(root, commit) + filename = "transfer.json" if role == "transfer" else "publication-transfer.json" + path = final_directory(owner, commit, selection) / filename + transfer = parse_transfer(path.read_bytes()) + if (transfer.target.tag, transfer.target.commit) != (release_tag(root), commit): + message = "saved transfer differs from the current release checkout" + raise ValueError(message) + if role == "publication" and "attached" not in {item[0] for item in transfer.assets}: + message = "publication requires the already captured canonical archive" + raise ValueError(message) + return path + + +def upload_dispatch(root: Path, owner: Path, selection: str, tag: str) -> None: + """Dispatch registry publication using only the existing reviewed acceptance.""" + if tag != release_tag(root): + message = "dispatch tag differs from the package release" + raise ValueError(message) + publication = record_path(root, owner, selection, "publication") + transfer_bytes = publication.read_bytes() + transfer = parse_transfer(transfer_bytes) + accepted = publication.parent / "acceptance.json" + expected = accepted.read_bytes() # Missing review must never create a new selection. + if verify_existing(root, owner, selection).read_bytes() != expected: + message = "reviewed acceptance changed before publication dispatch" + raise ValueError(message) + check_published_transfer(root, transfer) + if publication.read_bytes() != transfer_bytes or accepted.read_bytes() != expected: + message = "publication assertions changed before dispatch" + raise ValueError(message) + # Snapshot the same original bytes sent by the prior explicit CLI procedure. + with tempfile.TemporaryDirectory(prefix="la-stack-publication-dispatch-") as temporary: + snapshot = Path(temporary) / "publication-transfer.json" + snapshot.write_bytes(transfer_bytes) + run_command( + "gh", + [ + "workflow", + "run", + "publish-crates.yml", + "--repo", + REPOSITORY, + "--ref", + tag, + "-f", + f"tag={tag}", + "-F", + f"candidate=@{snapshot}", + "-f", + f"acceptance={hashlib.sha256(expected).hexdigest()}", + ], + cwd=root, + ) + + def require_dispatch(transfer: Transfer, *, published: bool) -> None: """Bind independent workflow input to the allowed operator, workflow and tag.""" workflow = "publish-crates.yml" if published else "release-benchmarks.yml" @@ -518,8 +694,8 @@ def dispatch_source(options: argparse.Namespace) -> Path | ReviewedAcceptance: return Path(options.incoming) -def main() -> None: - """Expose only repository orchestration; shared APIs own every evidence check.""" +def argument_parser() -> argparse.ArgumentParser: + """Declare explicit recovery inputs alongside automatic retained context.""" parser = argparse.ArgumentParser(description=__doc__) commands = parser.add_subparsers(dest="operation", required=True) measurement = commands.add_parser("acquire") @@ -528,19 +704,55 @@ def main() -> None: measurement.add_argument("--owner-directory", type=Path) measurement.add_argument("--progress", choices=("human", "json", "none"), default="human") measurement.add_argument("--progress-cadence", type=float, default=300) - for name in ("capture", "verify", "archive", "stage", "seal"): + for name in ("capture", "verify", "reviewed", "archive", "stage", "seal", "record", "upload"): command = commands.add_parser(name) - command.add_argument("owner_directory", type=Path) + if name == "record": + command.add_argument("role", choices=("transfer", "publication")) + if name == "upload": + command.add_argument("tag") + command.add_argument("owner_directory", nargs="?", default="") if name == "capture": command.add_argument("--retained-scratch", action="store_true", help="explicitly retry retention of the original locally controlled acquisition") else: command.add_argument("--selection", default="reviewed", help="immutable review record; choose a new name to review a different native CI attempt") + if name == "stage": + command.add_argument( + "--target", default="", help="explicit authenticated target.json for manual recovery; otherwise retrieve the original receipt" + ) receiver = commands.add_parser("receive") receiver.add_argument("destination", type=Path) receiver.add_argument("--published", action="store_true") receiver.add_argument("--incoming", type=Path) receiver.add_argument("--expected", type=Path) - options = parser.parse_args() + return parser + + +def run_owner_operation(root: Path, options: argparse.Namespace) -> None: + """Route operations after resolving their independent acquisition owner.""" + owner = selected_owner(root, options.owner_directory) + if options.operation == "capture": + print(json.dumps(capture(root, owner, retained_scratch=options.retained_scratch))) + remember_owner(root, owner) + elif options.operation == "verify": + accepted = verify(root, owner, options.selection) + print(json.dumps({"acceptance": str(accepted), "sha256": hashlib.sha256(accepted.read_bytes()).hexdigest()})) + elif options.operation == "reviewed": + print(verify_existing(root, owner, options.selection)) + elif options.operation == "archive": + print(archive(owner, resolve_revision(root, "HEAD"), options.selection)) + elif options.operation == "stage": + print(stage(root, owner, options.selection, Path(options.target) if options.target else None)) + elif options.operation == "seal": + print(seal(root, owner, options.selection)) + elif options.operation == "record": + print(record_path(root, owner, options.selection, options.role)) + else: + upload_dispatch(root, owner, options.selection, options.tag) + + +def main() -> None: + """Expose only repository orchestration; shared APIs own every evidence check.""" + options = argument_parser().parse_args() root = Path.cwd().resolve() if options.operation == "acquire": owner = owner_directory(root, options.checkpoint, options.owner_directory) @@ -556,18 +768,7 @@ def main() -> None: elif options.operation == "receive": print(receive(root, options.destination, os.environ["CANDIDATE_TRANSFER"].encode("utf-8"), source=dispatch_source(options))) else: - owner = owner_directory(root, "", options.owner_directory) - if options.operation == "capture": - print(json.dumps(capture(root, owner, retained_scratch=options.retained_scratch))) - elif options.operation == "verify": - accepted = verify(root, owner, options.selection) - print(json.dumps({"acceptance": str(accepted), "sha256": hashlib.sha256(accepted.read_bytes()).hexdigest()})) - elif options.operation == "archive": - print(archive(owner, resolve_revision(root, "HEAD"), options.selection)) - elif options.operation == "stage": - print(stage(root, owner, options.selection)) - else: - print(seal(root, owner, options.selection)) + run_owner_operation(root, options) if __name__ == "__main__": diff --git a/uv.lock b/uv.lock index 68e378d..363bf9e 100644 --- a/uv.lock +++ b/uv.lock @@ -433,12 +433,12 @@ tooling = [ dev = [ { name = "actionlint-py", specifier = "==1.7.12.25" }, { name = "pytest-xdist", specifier = "==3.8.0" }, - { name = "research-repo-tools", extras = ["python-tools", "semgrep"], git = "https://github.com/acgetchell/research-repo-tools?rev=055353c630604b1ae3438c1eaf534ba85e067a7e" }, + { name = "research-repo-tools", extras = ["python-tools", "semgrep"], git = "https://github.com/acgetchell/research-repo-tools?rev=cd8594f658b5bf3ade063a685834565cc62e0cb3" }, { name = "shellcheck-py", specifier = "==0.11.0.1" }, { name = "shfmt-py", specifier = "==4.2.0" }, { name = "yamllint", specifier = "==1.38.0" }, ] -tooling = [{ name = "research-repo-tools", extras = ["python-tools", "semgrep"], git = "https://github.com/acgetchell/research-repo-tools?rev=055353c630604b1ae3438c1eaf534ba85e067a7e" }] +tooling = [{ name = "research-repo-tools", extras = ["python-tools", "semgrep"], git = "https://github.com/acgetchell/research-repo-tools?rev=cd8594f658b5bf3ade063a685834565cc62e0cb3" }] [[package]] name = "markdown-it-py" @@ -900,7 +900,7 @@ wheels = [ [[package]] name = "research-repo-tools" version = "0.2.6" -source = { git = "https://github.com/acgetchell/research-repo-tools?rev=055353c630604b1ae3438c1eaf534ba85e067a7e#055353c630604b1ae3438c1eaf534ba85e067a7e" } +source = { git = "https://github.com/acgetchell/research-repo-tools?rev=cd8594f658b5bf3ade063a685834565cc62e0cb3#cd8594f658b5bf3ade063a685834565cc62e0cb3" } dependencies = [ { name = "packaging" }, { name = "pyyaml" }, From 6cb4fbca6fa4429b0a45d90b53403a774fc80ef5 Mon Sep 17 00:00:00 2001 From: Adam Getchell Date: Sun, 8 Nov 2026 12:35:45 -0800 Subject: [PATCH 2/4] docs: assign release validation to the release tracker - Close the completed release-flow and validation-graph implementation issues on merge and keep remaining release validation and readiness under #333. Closes #330 Closes #338 --- docs/dev/release-flow-integration.md | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/docs/dev/release-flow-integration.md b/docs/dev/release-flow-integration.md index 9bf8901..1abb5ed 100644 --- a/docs/dev/release-flow-integration.md +++ b/docs/dev/release-flow-integration.md @@ -125,10 +125,10 @@ Just rejection of publication without prior review. Windows-targeted static type checking and the exact lock check passed. Astra's source review found no remaining trust-boundary blocker; it did not substitute for native validation. -New native CI, authenticated hosted retained-evidence handoff and matched cold/warm managed -installation timings require the final reviewed commit. No speedup is claimed -from configuration, graph-fixture timing or these unmatched cache observations. -[#333](https://github.com/acgetchell/la-stack/issues/333) retains final published -package/native release adoption; #338 remains open for its native timing and -published-package acceptance until those are demonstrated. No release, tag or -benchmark campaign is run as part of this integration. +The implementation PR closes #330 and #338 on merge. +[#333](https://github.com/acgetchell/la-stack/issues/333) owns final native +validation, authenticated hosted retained-evidence handoff, matched cold/warm +managed installation timings and published-tooling adoption. No speedup is +claimed from configuration, graph-fixture timing or these unmatched cache +observations. No release, tag or benchmark campaign is run as part of this +integration. From 6d77e15be3eb3e2b3281baaf98cf56d8837037fa Mon Sep 17 00:00:00 2001 From: Adam Getchell Date: Sun, 8 Nov 2026 14:21:33 -0800 Subject: [PATCH 3/4] fix: avoid Windows actionlint deadlock in release preparation - Execute the draft writer from a runner-owned script through isolated Python, preserving the exact tag, attempt, artifact and draft identity checks. - Parse workflow scripts as Bash only for the applicable shell steps while retaining managed Zizmor execution enforcement. - Document automatic owner-record transfer selection and the completed workstream handoff to the release-validation tracker. - Refresh the locked bracex dependency to 3.2.2. Closes #326 Closes #330 Closes #338 --- .github/workflows/prepare-release.yml | 17 ++++----- docs/BENCHMARKING.md | 5 ++- docs/dev/release-flow-integration.md | 14 ++++++- semgrep.yaml | 18 ++++++++- .../.github/workflows/zizmor_policy.yml | 30 +++++++++++++++ tests/tooling/test_release_flow.py | 37 +++++++++++++++---- uv.lock | 6 +-- 7 files changed, 101 insertions(+), 26 deletions(-) diff --git a/.github/workflows/prepare-release.yml b/.github/workflows/prepare-release.yml index 576992d..a5ee308 100644 --- a/.github/workflows/prepare-release.yml +++ b/.github/workflows/prepare-release.yml @@ -93,16 +93,13 @@ jobs: HANDOFF_ID: ${{ needs.verify.outputs.artifact-id }} HANDOFF_SHA256: ${{ needs.verify.outputs.artifact-digest }} VALIDATED_ATTEMPT: ${{ needs.verify.outputs.validated-attempt }} - shell: bash + working-directory: ${{ runner.temp }} + # Run Python from the runner's script file, avoiding actionlint #651's stdin deadlock. + shell: >- + uv run --no-config --no-project --isolated --no-cache --python 3.15 + --with "research-repo-tools @ git+https://github.com/acgetchell/research-repo-tools@cd8594f658b5bf3ade063a685834565cc62e0cb3" + python -I "{0}" run: | - set -euo pipefail - # Recovery must retrieve the original journal, never start a new intent. - test "$GITHUB_RUN_ATTEMPT" = 1 - test "$VALIDATED_ATTEMPT" = "$GITHUB_RUN_ATTEMPT" - cd "$RUNNER_TEMP" - uv run --no-config --no-project --isolated --no-cache --python 3.15 \ - --with 'research-repo-tools @ git+https://github.com/acgetchell/research-repo-tools@cd8594f658b5bf3ade063a685834565cc62e0cb3' \ - python -I - <<'PY' import os from functools import partial from pathlib import Path @@ -117,6 +114,7 @@ jobs: run = int(os.environ["GITHUB_RUN_ID"]) attempt = int(os.environ["GITHUB_RUN_ATTEMPT"]) if (repository != "acgetchell/la-stack" or attempt != 1 + or os.environ["GITHUB_RUN_ATTEMPT"] != "1" or os.environ["VALIDATED_ATTEMPT"] != str(attempt) or os.environ["GITHUB_EVENT_NAME"] != "push" or os.environ["GITHUB_REF"] != "refs/tags/" + os.environ["RELEASE_TAG"] @@ -178,7 +176,6 @@ jobs: notes=(handoff / "notes.md").read_bytes(), receipt=root / "draft-receipt.json", approve=True, ) replace_many({root / "target.json": serialize_release_target(target)}) - PY - name: Retain journal even after an unconfirmed creation if: always() uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 diff --git a/docs/BENCHMARKING.md b/docs/BENCHMARKING.md index 7be73e1..4466a23 100644 --- a/docs/BENCHMARKING.md +++ b/docs/BENCHMARKING.md @@ -927,8 +927,9 @@ final clean commit, wait for the tag-triggered draft, stage the independent reco against its authenticated captured target, and dispatch attachment and publication. Both dispatches use the exact tag and owner-generated selection JSON; downloaded files cannot authenticate their own assertions. -`just release-benchmarks "$TAG"` dispatches attachment using the explicitly -selected `CANDIDATE_TRANSFER_FILE` and returns without waiting or acquiring data. +`just release-benchmarks "$TAG"` dispatches attachment using the transfer from +the retained owner record by default. `CANDIDATE_TRANSFER_FILE` is an optional +recovery override. Dispatch returns without waiting or acquiring data. ### Hosted Release Verification diff --git a/docs/dev/release-flow-integration.md b/docs/dev/release-flow-integration.md index 1abb5ed..d3dff7d 100644 --- a/docs/dev/release-flow-integration.md +++ b/docs/dev/release-flow-integration.md @@ -1,12 +1,22 @@ # Release and validation integration -Implementation record for [#330](https://github.com/acgetchell/la-stack/issues/330) +Implementation record for [#326](https://github.com/acgetchell/la-stack/issues/326), +[#330](https://github.com/acgetchell/la-stack/issues/330) and [#338](https://github.com/acgetchell/la-stack/issues/338). The starting source is `7c3a3625220773369c089e64e568a979abc0bb7f`. The authorized supplying revision is research-repo-tools `cd8594f658b5bf3ade063a685834565cc62e0cb3` (PRs #207 and #209). Its installed metadata remains 0.2.6; the Git commit, manifest, lock and isolated runtime assertions distinguish this development rehearsal from publication. +## Workstream readiness + +[PR #335](https://github.com/acgetchell/la-stack/pull/335) merged at the starting +source above. Its Linux, macOS, Windows, Cargo packaging and static checks passed; +all six prerequisites of #326 (#320 through #325) are closed. This integration +completes the remaining #330 command/guide and automatic-draft work, plus #338's +validation graph and cache wiring. Required PR checks and review still gate merge. +Final release validation and published-tooling adoption belong to #333. + ## Release contract The [release command comparison](../RELEASING.md#release-command-alignment) @@ -125,7 +135,7 @@ Just rejection of publication without prior review. Windows-targeted static type checking and the exact lock check passed. Astra's source review found no remaining trust-boundary blocker; it did not substitute for native validation. -The implementation PR closes #330 and #338 on merge. +The implementation PR closes #326, #330 and #338 on merge. [#333](https://github.com/acgetchell/la-stack/issues/333) owns final native validation, authenticated hosted retained-evidence handoff, matched cold/warm managed installation timings and published-tooling adoption. No speedup is diff --git a/semgrep.yaml b/semgrep.yaml index e7d9022..c12e321 100644 --- a/semgrep.yaml +++ b/semgrep.yaml @@ -488,8 +488,22 @@ rules: - "/tests/semgrep/.github/workflows/**/*.yml" - "/tests/semgrep/.github/workflows/**/*.yaml" patterns: - - pattern: | - run: $SCRIPT + - pattern-either: + - patterns: + - pattern: | + run: $SCRIPT + ... + - pattern-not-inside: | + shell: $SHELL + ... + - patterns: + - pattern: | + run: $SCRIPT + shell: $SHELL + ... + - metavariable-regex: + metavariable: $SHELL + regex: ^(?:bash|sh)(?:\s|$) - metavariable-pattern: metavariable: $SCRIPT language: bash diff --git a/tests/semgrep/.github/workflows/zizmor_policy.yml b/tests/semgrep/.github/workflows/zizmor_policy.yml index 458dd11..fe4df7f 100644 --- a/tests/semgrep/.github/workflows/zizmor_policy.yml +++ b/tests/semgrep/.github/workflows/zizmor_policy.yml @@ -19,3 +19,33 @@ jobs: set -euo pipefail uv run --locked --no-sync research-repo-tools \ toolchain run -- zizmor --persona regular --format sarif .github + # ruleid: la-stack.github-actions.zizmor-managed-execution + - run: zizmor --persona regular .github + shell: bash + # ok: la-stack.github-actions.zizmor-managed-execution + - run: uv run --locked --no-sync research-repo-tools toolchain run -- zizmor --persona regular .github + shell: bash + # ruleid: la-stack.github-actions.zizmor-managed-execution + - run: zizmor --persona regular .github + shell: bash --noprofile --norc -eo pipefail {0} + # ok: la-stack.github-actions.zizmor-managed-execution + - run: uv run --locked --no-sync research-repo-tools toolchain run -- zizmor --persona regular .github + shell: bash --noprofile --norc -eo pipefail {0} + # ruleid: la-stack.github-actions.zizmor-managed-execution + - run: zizmor --persona regular .github + shell: sh + # ok: la-stack.github-actions.zizmor-managed-execution + - run: uv run --locked --no-sync research-repo-tools toolchain run -- zizmor --persona regular .github + shell: sh + # ok: la-stack.github-actions.zizmor-managed-execution + - run: | + record = {"tag": "v0.5.42"} + if record["tag"] != "v0.5.42": + raise ValueError("wrong tag") + shell: python + # ok: la-stack.github-actions.zizmor-managed-execution + - run: | + record = {"tag": "v0.5.42"} + if record["tag"] != "v0.5.42": + raise ValueError("wrong tag") + shell: uv run --no-project --isolated python -I "{0}" diff --git a/tests/tooling/test_release_flow.py b/tests/tooling/test_release_flow.py index 07a893b..27c5e5f 100644 --- a/tests/tooling/test_release_flow.py +++ b/tests/tooling/test_release_flow.py @@ -184,7 +184,7 @@ def gh(_root: Path, args: list[str]) -> bytes: workflow = yaml.safe_load(WORKFLOW.read_bytes()) source = next(step["run"] for step in workflow["jobs"]["prepare"]["steps"] if "run" in step) script = tmp_path / "writer.py" - script.write_text(source.split("<<'PY'\n", 1)[1].rsplit("\nPY", 1)[0], encoding="utf-8", newline="\n") + script.write_text(source, encoding="utf-8", newline="\n") return {**state, "state": state, "provider": provider, "script": script} @@ -201,11 +201,15 @@ def test_actual_writer_creates_once_and_retains_exact_draft(draft_handoff: dict[ assert (target.tag, target.commit, target.release_id, target.expected_title) == (TAG, COMMIT, 42, TAG) -@pytest.mark.parametrize("failure", ["ref", "commit", "attempt", "artifact", "digest", "expired", "history", "workflow", "native"]) +@pytest.mark.parametrize( + "failure", ["ref", "commit", "attempt", "attempt-spelling", "validated-attempt", "artifact", "digest", "expired", "history", "workflow", "native"] +) def test_writer_rejects_changed_handoff_without_creating_a_draft( draft_handoff: dict[str, Any], tmp_path: Path, monkeypatch: pytest.MonkeyPatch, failure: str ) -> None: - if failure in {"ref", "commit", "attempt"}: + if failure in {"attempt-spelling", "validated-attempt"}: + monkeypatch.setenv("GITHUB_RUN_ATTEMPT" if failure == "attempt-spelling" else "VALIDATED_ATTEMPT", "01") + elif failure in {"ref", "commit", "attempt"}: monkeypatch.setenv({"ref": "GITHUB_REF", "commit": "GITHUB_SHA", "attempt": "GITHUB_RUN_ATTEMPT"}[failure], "changed") if failure == "attempt": monkeypatch.setenv("GITHUB_RUN_ATTEMPT", "2") @@ -218,7 +222,11 @@ def test_writer_rejects_changed_handoff_without_creating_a_draft( draft_handoff["current"]["path"] = ".github/workflows/other.yml" else: draft_handoff["provider"].jobs[0]["conclusion"] = "failure" - diagnostic = "original exact tag push" if failure in {"ref", "commit", "attempt"} else "handoff|preparation intent|did not succeed" + diagnostic = ( + "original exact tag push" + if failure in {"ref", "commit", "attempt", "attempt-spelling", "validated-attempt"} + else "handoff|preparation intent|did not succeed" + ) with pytest.raises(ValueError, match=diagnostic): runpy.run_path(str(draft_handoff["script"]), run_name="__main__") assert draft_handoff["posts"] == [] @@ -233,9 +241,24 @@ def test_draft_workflow_keeps_the_writer_isolated_and_recovery_explicit() -> Non assert writer["needs"] == "verify" assert writer["permissions"] == {"actions": "read", "contents": "write"} assert all(not step.get("uses", "").startswith("./") and "checkout@" not in step.get("uses", "") for step in writer["steps"]) - script = writer["steps"][1]["run"] - assert 'test "$GITHUB_RUN_ATTEMPT" = 1' in script - assert "--no-config --no-project --isolated --no-cache" in script + step = writer["steps"][1] + # Custom shells use .NET argument parsing; single quotes are not delimiters. + command = shlex.split(step["shell"], posix=False) + assert command[:9] == ["uv", "run", "--no-config", "--no-project", "--isolated", "--no-cache", "--python", "3.15", "--with"] + assert command[9].startswith('"research-repo-tools @ git+https://github.com/acgetchell/research-repo-tools@') + assert command[9].endswith('"') + assert command[10:] == ["python", "-I", '"{0}"'] + assert step["working-directory"] == "${{ runner.temp }}" assert writer["steps"][-1]["if"] == "always()" assert "draft-receipt.json" in writer["steps"][-1]["with"]["path"] assert all(step.get("with", {}).get("enable-cache", False) is False for step in writer["steps"]) + + +def test_draft_workflow_avoids_actionlint_prestart_pipe_deadlock() -> None: + """The pinned linter writes Bash scripts before starting their pipe reader.""" + workflow = yaml.safe_load(WORKFLOW.read_bytes()) + for job in workflow["jobs"].values(): + for step in job["steps"]: + if "run" in step and step.get("shell", "bash").split()[0] in {"bash", "sh"}: + payload = ("set -eo pipefail\n" + step["run"] + "\n").encode("utf-8") + assert len(payload) <= 4096, f"{step['name']} sends {len(payload)} bytes to actionlint's pre-start Windows pipe" diff --git a/uv.lock b/uv.lock index 363bf9e..fb3c20b 100644 --- a/uv.lock +++ b/uv.lock @@ -71,11 +71,11 @@ wheels = [ [[package]] name = "bracex" -version = "3.2.1" +version = "3.2.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/4d/fc/137095eb2f24ccf0b4db196abd9f5cfe74398a834af51d4bc4876e0beaf0/bracex-3.2.1.tar.gz", hash = "sha256:07a9b2100ceecc0c24afa1a1714bf8d4a6abb43d592939455f662b682ac9baa4", size = 32694, upload-time = "2026-11-03T14:13:43.53Z" } +sdist = { url = "https://files.pythonhosted.org/packages/2d/1f/b7ce2efe406b87bb3693d587b91bcd8fa9030888870ceff29e4a0d233cb2/bracex-3.2.2.tar.gz", hash = "sha256:d18976160b3ba01a57d5c53a5c8c3aa2506efa9e0a3a62467251f82b8f563fde", size = 34980, upload-time = "2026-11-07T21:28:25.433Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/47/ae/30dd8b027b4032b27e81364de5b74bfbef50b3c84c6aa0ead351bbb3749c/bracex-3.2.1-py3-none-any.whl", hash = "sha256:e8622d46b60ef6062b4091033f97a304e1b205e70c0285743c5038b73886a47e", size = 12527, upload-time = "2026-11-03T14:13:42.032Z" }, + { url = "https://files.pythonhosted.org/packages/21/b5/52a750a062db07ba056cde1c52c802b898a96b70953389a0b2247e18f210/bracex-3.2.2-py3-none-any.whl", hash = "sha256:284d59c4656e5a0421ff691402074bd5847049d50410535764a2c522a47095bc", size = 14151, upload-time = "2026-11-07T21:28:24.028Z" }, ] [[package]] From 261bee3b5a8b7db2f9fff8820066a669b0e4031d Mon Sep 17 00:00:00 2001 From: Adam Getchell Date: Sun, 8 Nov 2026 16:26:06 -0800 Subject: [PATCH 4/4] fix(ci): authenticate tool setup and allow Zizmor cleanup - Use the job token for managed binary release lookups so cold setup can finish when the anonymous GitHub API quota is exhausted. - Give Zizmor 45 minutes for cold tool installation, scanning and cache cleanup while preserving the existing findings gate. Refs #338 --- .github/actions/setup-tools/action.yml | 1 + .github/workflows/zizmor.yml | 3 +- tests/tooling/test_validation_graph.py | 46 ++++++++++++++++++++++++-- 3 files changed, 47 insertions(+), 3 deletions(-) diff --git a/.github/actions/setup-tools/action.yml b/.github/actions/setup-tools/action.yml index 5718a9c..4286f3a 100644 --- a/.github/actions/setup-tools/action.yml +++ b/.github/actions/setup-tools/action.yml @@ -30,6 +30,7 @@ runs: - name: Set up declared tools and development environment shell: bash env: + GH_TOKEN: ${{ github.token }} RESEARCH_REPO_TOOLS_HOME: ${{ runner.temp }}/rrt RESEARCH_REPO_TOOLS_CARGO_BUILD_HOME: ${{ runner.temp }}/rrt-cargo-build run: uv run --locked --managed-python --only-group tooling research-repo-tools setup diff --git a/.github/workflows/zizmor.yml b/.github/workflows/zizmor.yml index 75cbc36..5846425 100644 --- a/.github/workflows/zizmor.yml +++ b/.github/workflows/zizmor.yml @@ -21,7 +21,8 @@ jobs: analyze: name: GitHub Actions Security Analysis runs-on: ubuntu-latest - timeout-minutes: 30 + # Cold managed-tool setup took 29m23s; leave time for scanning and cache cleanup. + timeout-minutes: 45 permissions: actions: read contents: read diff --git a/tests/tooling/test_validation_graph.py b/tests/tooling/test_validation_graph.py index 8728a91..28a809d 100644 --- a/tests/tooling/test_validation_graph.py +++ b/tests/tooling/test_validation_graph.py @@ -4,14 +4,21 @@ import os import shlex import sys +from email.message import Message +from io import BytesIO from pathlib import Path -from typing import Any +from typing import TYPE_CHECKING, Any +from urllib.error import HTTPError import pytest import yaml +from research_repo_tools import prebuilt_tools from research_repo_tools.just_inspect import inspect_justfile from research_repo_tools.process import run_command +if TYPE_CHECKING: + from urllib.request import Request + ROOT = Path(__file__).resolve().parents[2] POLICY = { "action-lint", @@ -120,7 +127,11 @@ def test_managed_cache_contains_compilation_only_and_release_jobs_disable_it() - assert all(name in cache["with"]["key"] for name in ("rust-toolchain.toml", "pyproject.toml", "uv.lock", "Cargo.lock")) assert "restore-keys" not in cache["with"] setup = next(step for step in steps if step.get("run", "").endswith("research-repo-tools setup")) - assert setup["env"] == {"RESEARCH_REPO_TOOLS_HOME": "${{ runner.temp }}/rrt", "RESEARCH_REPO_TOOLS_CARGO_BUILD_HOME": cache["with"]["path"]} + assert setup["env"] == { + "GH_TOKEN": "${{ github.token }}", + "RESEARCH_REPO_TOOLS_HOME": "${{ runner.temp }}/rrt", + "RESEARCH_REPO_TOOLS_CARGO_BUILD_HOME": cache["with"]["path"], + } for filename in ("ci.yml", "release-benchmarks.yml", "publish-crates.yml", "prepare-release.yml"): workflow = yaml.safe_load((ROOT / ".github/workflows" / filename).read_bytes()) for name, job in workflow["jobs"].items(): @@ -130,3 +141,34 @@ def test_managed_cache_contains_compilation_only_and_release_jobs_disable_it() - if step.get("uses") == "./.github/actions/setup-tools": assert str(step["with"]["cache"]).lower() == "false" assert not step.get("uses", "").startswith("actions/cache@") + + +@pytest.mark.parametrize("authenticated", [False, True], ids=["rate-limited-anonymous", "authenticated-setup"]) +def test_setup_token_authenticates_installed_binary_release_lookup(monkeypatch: pytest.MonkeyPatch, authenticated: bool) -> None: + """Exercise the real client's credential boundary using the action's mapping.""" + action = yaml.safe_load((ROOT / ".github/actions/setup-tools/action.yml").read_bytes()) + setup = next(step for step in action["runs"]["steps"] if step.get("run", "").endswith("research-repo-tools setup")) + credential = next(name for name, value in setup["env"].items() if value == "${{ github.token }}") + for name in ("GH_TOKEN", "GITHUB_TOKEN"): + monkeypatch.delenv(name, raising=False) + if authenticated: + monkeypatch.setenv(credential, "fixture-lookup-token") + observed: list[str | None] = [] + + def lookup(request: Request, *, timeout: float) -> BytesIO: + assert request.full_url == "https://api.github.com/repos/gitleaks/gitleaks/releases/tags/v8.30.1" + assert timeout == 30 + authorization = request.get_header("Authorization") + observed.append(authorization) + if authorization != "Bearer fixture-lookup-token": + raise HTTPError(request.full_url, 403, "rate limit exceeded", Message(), None) + return BytesIO(json.dumps({"draft": False, "prerelease": False, "tag_name": "v8.30.1"}).encode()) + + monkeypatch.setattr(prebuilt_tools.urllib.request, "urlopen", lookup) + if authenticated: + assert prebuilt_tools.release_metadata("gitleaks", "v8.30.1")["tag_name"] == "v8.30.1" + assert observed == ["Bearer fixture-lookup-token"] + else: + with pytest.raises(RuntimeError, match="HTTP Error 403: rate limit exceeded"): + prebuilt_tools.release_metadata("gitleaks", "v8.30.1") + assert observed == [None]