diff --git a/.github/workflows/pypi.yml b/.github/workflows/pypi.yml index 836b516..c32495e 100644 --- a/.github/workflows/pypi.yml +++ b/.github/workflows/pypi.yml @@ -1,4 +1,4 @@ -name: Publish to PyPI +name: Validate or publish release on: release: @@ -6,48 +6,108 @@ on: workflow_dispatch: inputs: ref: - description: Git ref to build and publish, for example `v0.0.1` + description: Ref to validate; publication requires v required: true type: string + publish: + description: Publish validated artifacts to PyPI after environment approval + required: true + default: false + type: boolean permissions: contents: read +defaults: + run: + shell: bash + jobs: - build: - name: Build distributions + resolve: runs-on: ubuntu-latest + outputs: + sha: ${{ steps.identity.outputs.sha }} + version: ${{ steps.identity.outputs.version }} + publish: ${{ steps.identity.outputs.publish }} steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 with: ref: ${{ github.event_name == 'workflow_dispatch' && inputs.ref || github.event.release.tag_name }} - - - uses: actions/setup-python@v5 + fetch-depth: 0 + persist-credentials: false + - uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v7 with: - python-version: "3.10" + version: "0.11.17" + python-version: "3.14" + - name: Resolve and validate release identity + id: identity + env: + RELEASE_REF: ${{ github.event_name == 'workflow_dispatch' && inputs.ref || github.event.release.tag_name }} + PUBLISH: ${{ github.event_name == 'release' || inputs.publish }} + run: | + PROJECT_VERSION="$(uv version --short)" uv run --locked --only-group dev python tools/check_release.py - - uses: astral-sh/setup-uv@v7 + validate: + needs: resolve + uses: ./.github/workflows/validate.yml + with: + ref: ${{ needs.resolve.outputs.sha }} - - run: uv build - - - uses: actions/upload-artifact@v4 + evidence: + needs: [resolve, validate] + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + ref: ${{ needs.resolve.outputs.sha }} + persist-credentials: false + - uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v7 + with: + version: "0.11.17" + python-version: "3.14" + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 with: - name: python-package-distributions + name: candidate-distributions path: dist/ + - name: Record validated artifact identity + env: + SOURCE_SHA: ${{ needs.resolve.outputs.sha }} + PROJECT_VERSION: ${{ needs.resolve.outputs.version }} + run: | + uv run --locked --only-group dev python tools/check_distribution.py dist/*.whl dist/*.tar.gz > release-evidence.json + python - <<'PY' + import json + import os + from pathlib import Path + path = Path("release-evidence.json") + evidence = json.loads(path.read_text()) + if evidence["version"] != os.environ["PROJECT_VERSION"]: + raise SystemExit("Validated artifact version differs from resolved project version") + evidence["source_sha"] = os.environ["SOURCE_SHA"] + evidence["run_url"] = f"{os.environ['GITHUB_SERVER_URL']}/{os.environ['GITHUB_REPOSITORY']}/actions/runs/{os.environ['GITHUB_RUN_ID']}" + path.write_text(json.dumps(evidence, indent=2) + "\n") + print(path.read_text()) + PY + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: release-evidence + path: release-evidence.json + if-no-files-found: error publish: - name: Publish to PyPI - needs: build + name: Publish validated artifacts to PyPI + if: needs.resolve.outputs.publish == 'true' + needs: [resolve, validate, evidence] runs-on: ubuntu-latest environment: name: pypi url: https://pypi.org/p/bearshape permissions: + contents: read id-token: write steps: - - uses: actions/download-artifact@v4 + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 with: - name: python-package-distributions + name: candidate-distributions path: dist/ - - - uses: pypa/gh-action-pypi-publish@release/v1 + - uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # release/v1 diff --git a/CHANGELOG.md b/CHANGELOG.md index 61446cf..3987146 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -83,6 +83,9 @@ and this project follows ### Changed +- Require immutable tag/version identity and full candidate validation before + publishing the tested artifacts; add a validation-only release path. + - Prepare `0.1.0rc0` with beartype `>=0.23.0rc0,<0.24`; older beartype versions are no longer supported. Compatibility jobs test the exact rc0 dependency, including Python 3.10 and 3.14 CPU backend environments. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9da5afa..f7609f2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -105,3 +105,36 @@ wheel normally in a temporary environment, then runs copied runtime and checker fixtures with no package source directory. It reports artifact hashes and installed module origins. `--installed-package` is an explicit pytest mode for this consumer check; ordinary source validation continues to include `src`. + +## Validate and publish a release + +Run a validation-only workflow before making a release decision: + +```bash +gh workflow run pypi.yml --ref main -f ref= -F publish=false +``` + +This resolves one commit, runs the complete shared matrix, and produces +`candidate-distributions` plus `release-evidence`. The evidence records the +source SHA, version, artifact hashes and run URL. A failed required check +prevents publication. GPU runtime evidence and open contract decisions still +require review; CPU CI does not replace them. + +Publication requires a canonical `v` tag already in `main` +history. Both the workflow and package must come from that exact tag and commit. +Versions use `X.Y.Z` with an optional `aN`, `bN` or `rcN` suffix. A GitHub +release must be marked as a prerelease exactly when its version has such a +suffix. After explicit owner approval, publish that GitHub release, or dispatch +the workflow from the same tag with `publish=true`. Publishing downloads the +same tested distributions and does not rebuild them. Only that job receives OIDC +permission and enters the `pypi` environment. + +Before enabling publication, configure required review for `pypi`, prevent +self-approval and administrator bypass, and limit its deployment policy to +release tags. Protect `main` and release tags, and require the final validation +check on pull requests. Confirm the PyPI trusted publisher identifies the actual +repository owner/name, `pypi.yml`, and environment `pypi`. The workflow does not +create these controls. The 2026-09-08 inspection found unprotected `main`, no +repository rulesets and no `pypi` approval reviewers; PyPI configuration remains +unverified. Ownership transfer requires rechecking publisher identity and docs +hosting before changing public URLs. diff --git a/plans/2026-09-08-publication-gate.md b/plans/2026-09-08-publication-gate.md new file mode 100644 index 0000000..fb6d205 --- /dev/null +++ b/plans/2026-09-08-publication-gate.md @@ -0,0 +1,80 @@ +# Publish only the exact validated release artifacts + + +Maintain this ExecPlan according to PLANS.md. This focused M9 PR builds on aggregate #27. Implementation is authorized; publication, access-control changes and ownership transfer are not. + +## Purpose / Big Picture + + +A release must not bypass runtime, typing, documentation or installed-package validation. Resolve the requested ref to one immutable commit, validate it fully, and publish the exact wheel and source archive that passed. Maintainers also need a validation-only path that produces inspectable evidence without uploading to PyPI. + +## Progress + + +- [x] (2026-09-08) Created codex/publication-gate and matching worktree from 51b5f08. +- [x] (2026-09-08) Inspected current publication workflow, reusable validation, GitHub protection settings and official OIDC guidance. +- [x] (2026-09-08) Opened #28 and implemented matching workflow/tag/commit, version, prerelease and main-ancestry gates. +- [x] (2026-09-08) All 19 real-Git-history tests pass; inspected 128 publication-result combinations and exact artifact/OIDC dependencies. +- [x] (2026-09-08) Full hooks/actionlint and all PR checks pass. Validation-only run 34221337124 succeeded with every required job green and publication skipped. +- [x] (2026-09-08) CONTRIBUTING records the release procedure and inspected/proposed administrative controls. +- [ ] Obtain user review before merge. + +## Surprises & Discoveries + + +The current publish workflow accepts any dispatch ref, builds it and publishes without test dependencies. Main has no branch protection; repository rulesets are empty. The pypi environment allows main branches and v* tags, has no approval reviewers and permits administrator bypass. The github-pages environment allows docs/main branches. These are read-only observations on 2026-09-08; no controls were changed. PyPI trusted-publisher configuration is not verified through the available repository API. + +## Decision Log + + +Decision: Keep release-published events and add dispatch publication defaulting to false. Rationale: validation-only runs should be possible for a branch/commit without making it eligible for publication. Actual publication must use a canonical v tag whose commit is already on main and whose event prerelease flag matches the version. Date: 2026-09-08. + +Decision: Use the existing reusable validation and its candidate-distributions artifact. Rationale: the wheel tested by minimal and installed consumers must be the wheel published; no second build is allowed. Resolve once and pass the immutable SHA to every checkout. Record archive hashes, version and source SHA separately from the dist directory. + +Decision: Keep OIDC write permission only on the final protected pypi job, with pinned actions and no long-lived token. Rationale: build and validation jobs do not need publication credentials. Environment reviewers and tag/main protections are administrative acceptance items, not implied by YAML. + +## Outcomes & Retrospective + + +The release workflow now validates one immutable commit and publishes only its tested artifact pair. The actual validation-only run at 3ee4c1d succeeded, produced provenance and skipped the OIDC publisher. Tests reject branch publication, mismatched versions/prerelease flags/workflow identity, missing or mismatched tags and unmerged commits. A04 native union rollback and CuPy static policy remain release blockers outside this workflow change. A green workflow does not authorize publication or establish unconfigured administrative protections. + +## Context and Orientation + + +Worktree /Users/ale/Code/bearshape-worktrees/publication-gate, branch codex/publication-gate. .github/workflows/pypi.yml is the existing release/dispatch entry point. .github/workflows/validate.yml owns all required CPU, checker, dependency, docs, notebook and artifact jobs. tools/check_distribution.py validates archive metadata and contents. tools/check_installed.py consumes copies of downstream tests outside src. pyproject.toml declares 0.1.0rc0 and beartype >=0.23.0rc0,<0.24. + +## Plan of Work + + +Add a small release validation tool that reads the event and resolved repository state, validates the canonical tag/version relationship for publication and prerelease status, and emits JSON plus GitHub job outputs. Use argument-list subprocess calls and strict ref handling; reject branch publication, mismatched versions, unresolved tags and tags outside main history. Permit arbitrary repository refs only in validation-only mode. + +Refactor pypi.yml into resolve, reusable validation, evidence and publish jobs. The evidence job consumes the existing distributions, checks their metadata version against the resolved project version, and records SHA256/source commit/run URL. Publish downloads those same immutable artifacts and needs successful validation and evidence. Validation-only runs skip publication explicitly. + +Add focused tests for valid rc/final releases and rejected branch, mismatch, prerelease and ancestry cases. Validate the workflow graph and failed-required-job exclusion without uploading anything. Keep source-archive consumer tests runnable when they reference the release helper. Document the command, release procedure and concrete settings still requiring the owner's authorization. + +## Concrete Steps + + +From this worktree run targeted pytest for release validation, then uv run --locked prek run -a and its manual actionlint stage. Exercise the release tool with temporary Git repositories and synthetic GitHub event files. Build and inspect artifacts, then run the hosted candidate matrix. Exercise a validation-only dispatch only if GitHub can run this branch workflow without merging it; otherwise record that platform constraint and use the PR-hosted equivalent plus local event tests. + +## Validation and Acceptance + + +A branch or version/prerelease mismatch must fail before publication eligibility. A tag outside main history must fail. Every required validation job must succeed for the immutable SHA. Published artifact selection must refer to the validated artifact without rebuilding. Evidence must contain source SHA, version, wheel/sdist hashes and run link. Validation-only mode must never enter the OIDC publisher job. Actual publishing and administrative settings remain pending explicit authorization. + +## Idempotence and Recovery + + +Use temporary Git repositories for destructive tag/ancestry tests. Never create release tags in the real repository, publish GitHub releases, upload packages, change controls or transfer ownership during validation. Preserve diagnostic logs and retry only understood failures. Main and focused PR branches remain untouched. + +## Artifacts and Notes + + +Store local evidence under /Users/ale/Code/bearshape-implementation-2026-09-08/evidence/publication-*. Record exact GitHub protection observations and proposed controls in the handoff documentation. Archive hash evidence is separate from the files passed to PyPI. + +## Interfaces and Dependencies + + +Use the standard library and existing locked uv tools. Keep the supported Python package range unchanged. GitHub Actions uses immutable action pins, the local reusable validation workflow, artifact upload/download, and pypa/gh-action-pypi-publish with environment pypi and job-scoped id-token: write. No new runtime dependency. + +Revision note — 2026-09-08: Verified the actual validation-only workflow at https://github.com/acecchini/bearshape/actions/runs/34221337124. Source 3ee4c1d8a0c3d22f537a24b447a9d75e025a5b2d; wheel SHA256 c1806203da013c9eaf2482a309c686a984c0a7031a495efbd100a824536b57d2; sdist SHA256 e2454095144fb4bb5a08d3bd998d0f6a8bee4bc5ad81dfab763c339ca91666df. Administrative protections remain unconfigured and publication unauthorized. diff --git a/tests/test_release.py b/tests/test_release.py new file mode 100644 index 0000000..92aac3a --- /dev/null +++ b/tests/test_release.py @@ -0,0 +1,213 @@ +"""Publication identity checks against real disposable Git histories.""" + +# Synthetic repositories only; commands use argument lists, never a shell. + +from __future__ import annotations + +import json +import os +import runpy +import shutil +import subprocess +import sys +from collections.abc import Callable +from pathlib import Path +from typing import TypeAlias + +import pytest + +_SCRIPT = Path(__file__).resolve().parents[1] / "tools/check_release.py" +resolve_release = runpy.run_path(str(_SCRIPT))["resolve_release"] +Repository: TypeAlias = tuple[Path, Callable[..., str]] + + +@pytest.fixture +def repository(tmp_path: Path) -> Repository: + git = shutil.which("git") + assert git is not None + + def command(*args: str) -> str: + return subprocess.check_output( + [ + git, + "-c", + "user.name=Release test", + "-c", + "user.email=release@example.invalid", + *args, + ], + cwd=tmp_path, + text=True, + stderr=subprocess.PIPE, + ).strip() + + command("init", "-b", "main") + (tmp_path / "content").write_text("first\n") + command("add", "content") + command("commit", "-m", "Initial synthetic release") + command("update-ref", "refs/remotes/origin/main", "HEAD") + command("tag", "v0.1.0rc0") + return tmp_path, command + + +def resolve(repository: Repository, **overrides: object) -> dict[str, str]: + root, git = repository + options = { + "repository": root, + "version": "0.1.0rc0", + "requested_ref": "v0.1.0rc0", + "event_name": "workflow_dispatch", + "workflow_ref": "refs/tags/v0.1.0rc0", + "workflow_sha": git("rev-parse", "HEAD"), + "event": {}, + "publish": True, + } + options.update(overrides) + return resolve_release(**options) + + +def published_event(version: str, *, prerelease: bool, draft: bool = False) -> dict: + return { + "action": "published", + "release": {"tag_name": f"v{version}", "prerelease": prerelease, "draft": draft}, + } + + +def test_candidate_tag_resolves_to_immutable_commit(repository: Repository) -> None: + _, git = repository + assert resolve(repository) == { + "sha": git("rev-parse", "HEAD"), + "version": "0.1.0rc0", + "publish": "true", + } + + +@pytest.mark.parametrize("version,prerelease", [("0.1.0rc0", True), ("0.1.0", False)]) +def test_release_event_requires_matching_prerelease_status( + repository: Repository, version: str, prerelease: bool +) -> None: + _, git = repository + git("tag", "-f", f"v{version}") + assert ( + resolve( + repository, + version=version, + requested_ref=f"v{version}", + workflow_ref=f"refs/tags/v{version}", + event_name="release", + event=published_event(version, prerelease=prerelease), + publish=False, + )["publish"] + == "true" + ) + with pytest.raises(ValueError, match="prerelease status"): + resolve( + repository, + version=version, + requested_ref=f"v{version}", + workflow_ref=f"refs/tags/v{version}", + event_name="release", + event=published_event(version, prerelease=not prerelease), + ) + + +@pytest.mark.parametrize( + "ref", ["main", "refs/heads/main", "v0.1.0", "v0.1.0rc1", "v0.1.0rc0\nevil"] +) +def test_arbitrary_ref_cannot_publish(repository: Repository, ref: str) -> None: + with pytest.raises(ValueError, match="exact version tag"): + resolve(repository, requested_ref=ref) + + +@pytest.mark.parametrize( + "version", ["01.1.0", "0.1.0+local", "0.1.0.dev0", "0.1.0.post1"] +) +def test_non_release_version_is_rejected(repository: Repository, version: str) -> None: + with pytest.raises(ValueError, match="Release version"): + resolve(repository, version=version, requested_ref=f"v{version}") + + +def test_missing_tag_is_rejected(repository: Repository) -> None: + with pytest.raises(subprocess.CalledProcessError): + resolve( + repository, + version="0.1.0rc1", + requested_ref="v0.1.0rc1", + workflow_ref="refs/tags/v0.1.0rc1", + ) + + +def test_tag_must_identify_checked_out_commit(repository: Repository) -> None: + root, git = repository + (root / "content").write_text("second\n") + git("commit", "-am", "Another synthetic commit") + git("update-ref", "refs/remotes/origin/main", "HEAD") + with pytest.raises(ValueError, match="checked-out commit"): + resolve(repository) + + +def test_release_commit_must_already_be_on_main(repository: Repository) -> None: + root, git = repository + (root / "content").write_text("unmerged\n") + git("commit", "-am", "Unmerged synthetic candidate") + git("tag", "v0.1.0rc1") + with pytest.raises(ValueError, match="main history"): + resolve( + repository, + version="0.1.0rc1", + requested_ref="v0.1.0rc1", + workflow_ref="refs/tags/v0.1.0rc1", + ) + + +def test_draft_release_cannot_publish(repository: Repository) -> None: + with pytest.raises(ValueError, match="non-draft"): + resolve( + repository, + event_name="release", + event=published_event("0.1.0rc0", prerelease=True, draft=True), + ) + + +def test_validation_only_dispatch_emits_nonpublishing_identity( + repository: Repository, +) -> None: + root, git = repository + event_path, output_path = root / "event.json", root / "output.txt" + event_path.write_text("{}") + environment = dict( + os.environ, + GITHUB_EVENT_PATH=str(event_path), + GITHUB_EVENT_NAME="workflow_dispatch", + GITHUB_REF="refs/heads/main", + GITHUB_WORKFLOW_SHA=git("rev-parse", "HEAD"), + GITHUB_OUTPUT=str(output_path), + PROJECT_VERSION="0.1.0rc0", + RELEASE_REF="main", + PUBLISH="false", + ) + result = subprocess.run( + [sys.executable, str(_SCRIPT)], + cwd=root, + env=environment, + check=True, + capture_output=True, + text=True, + ) + assert json.loads(result.stdout) == { + "sha": git("rev-parse", "HEAD"), + "version": "0.1.0rc0", + "publish": "false", + } + assert "publish=false\n" in output_path.read_text() + + +@pytest.mark.parametrize( + "override", + [{"workflow_ref": "refs/heads/main"}, {"workflow_sha": "0" * 40}], +) +def test_publication_workflow_must_match_candidate( + repository: Repository, override: dict[str, str] +) -> None: + with pytest.raises(ValueError, match="matching version tag and commit"): + resolve(repository, **override) diff --git a/tools/check_distribution.py b/tools/check_distribution.py index 5b9f8cf..01d8133 100644 --- a/tools/check_distribution.py +++ b/tools/check_distribution.py @@ -26,6 +26,7 @@ "ruff.toml", "tools/check_distribution.py", "tools/check_installed.py", + "tools/check_release.py", "tools/validate_runtime.py", "tools/validate_tox_env.py", "tests/conftest.py", diff --git a/tools/check_installed.py b/tools/check_installed.py index 50c96e1..2162f7a 100644 --- a/tools/check_installed.py +++ b/tools/check_installed.py @@ -42,9 +42,7 @@ def _copy_consumers(sdist: Path, destination: Path) -> None: for member in archive.getmembers(): name = member.name.removeprefix(prefix) if not member.isfile() or not ( - name.startswith("tests/") - or name in _CONFIGS - or name == "tools/validate_runtime.py" + name.startswith(("tests/", "tools/")) or name in _CONFIGS ): continue target = (destination / name).resolve() diff --git a/tools/check_release.py b/tools/check_release.py new file mode 100644 index 0000000..ff59b72 --- /dev/null +++ b/tools/check_release.py @@ -0,0 +1,104 @@ +"""Resolve release identity before allowing artifact validation or publication.""" + +# Git commands use argument lists and never a shell. +# ruff: noqa: S404, S603 + +from __future__ import annotations + +import json +import os +import re +import shutil +import subprocess +import sys +from pathlib import Path + +_VERSION = re.compile( + r"(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:(a|b|rc)(0|[1-9]\d*))?" +) + + +def resolve_release( + *, + repository: Path, + version: str, + requested_ref: str, + event_name: str, + workflow_ref: str, + workflow_sha: str, + event: dict, + publish: bool, +) -> dict[str, str]: + """Reject publication unless version, tag, ancestry and event agree.""" + match = _VERSION.fullmatch(version) + if match is None: + message = f"Release version must be X.Y.Z with an optional a/b/rc suffix: {version}" + raise ValueError(message) + if event_name not in {"release", "workflow_dispatch"}: + message = f"Unsupported release event: {event_name}" + raise ValueError(message) + git = shutil.which("git") + if git is None: + message = "git is required to resolve the release commit" + raise RuntimeError(message) + + def revision(ref: str) -> str: + return subprocess.check_output( + [git, "rev-parse", "--verify", ref], cwd=repository, text=True + ).strip() + + commit = revision("HEAD^{commit}") + if event_name == "release": + release = event["release"] + if event["action"] != "published" or release["draft"]: + message = "Only a published, non-draft GitHub release can publish" + raise ValueError(message) + if requested_ref != release["tag_name"]: + message = "Requested ref differs from the published release tag" + raise ValueError(message) + if release["prerelease"] != (match.group(4) is not None): + message = "GitHub prerelease status differs from the package version" + raise ValueError(message) + publish = True + + if publish: + if requested_ref != f"v{version}": + message = f"Publication requires the exact version tag v{version}" + raise ValueError(message) + if workflow_ref != f"refs/tags/{requested_ref}" or workflow_sha != commit: + message = "Publication workflow must run from the matching version tag and commit" + raise ValueError(message) + if revision(f"refs/tags/{requested_ref}^{{commit}}") != commit: + message = "Release tag does not identify the checked-out commit" + raise ValueError(message) + ancestry = subprocess.run( + [git, "merge-base", "--is-ancestor", commit, "refs/remotes/origin/main"], + cwd=repository, + check=False, + ) + if ancestry.returncode == 1: + message = "Release commit must already belong to origin/main history" + raise ValueError(message) + ancestry.check_returncode() + return {"sha": commit, "version": version, "publish": str(publish).lower()} + + +def main() -> None: + event = json.loads(Path(os.environ["GITHUB_EVENT_PATH"]).read_text(encoding="utf-8")) + result = resolve_release( + repository=Path.cwd(), + version=os.environ["PROJECT_VERSION"], + requested_ref=os.environ["RELEASE_REF"], + event_name=os.environ["GITHUB_EVENT_NAME"], + workflow_ref=os.environ["GITHUB_REF"], + workflow_sha=os.environ["GITHUB_WORKFLOW_SHA"], + event=event, + publish=os.environ["PUBLISH"] == "true", + ) + sys.stdout.write(json.dumps(result, indent=2) + "\n") + with Path(os.environ["GITHUB_OUTPUT"]).open("a", encoding="utf-8") as output: + output.writelines(f"{name}={value}\n" for name, value in result.items()) + + +if __name__ == "__main__": + main()