diff --git a/.github/workflows/package.yml b/.github/workflows/package.yml index 5011db2..9c14e74 100644 --- a/.github/workflows/package.yml +++ b/.github/workflows/package.yml @@ -17,12 +17,14 @@ on: - "scripts/verify_release_assets.py" - "scripts/validate_changelog.py" - "scripts/validate_release_ref.py" + - "scripts/validate_release_provenance.py" - "CHANGELOG.md" - "examples/**" - "compatibility/**" - "docs/releasing.md" - "tests/test_package_workflow.py" - "tests/test_verify_release_assets.py" + - "tests/test_validate_release_provenance.py" - "requirements/release.in" - "requirements/release.txt" - ".github/workflows/package.yml" @@ -235,9 +237,31 @@ jobs: - name: Check installed dependency consistency run: python -m pip check + provenance: + name: Verify reviewed release provenance + needs: [build, smoke] + if: ${{ (github.event_name == 'push' && github.ref_type == 'tag') || github.event_name == 'workflow_dispatch' }} + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + steps: + - name: Check out source + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + + - name: Fetch trusted main history + run: git fetch --no-tags --prune origin "refs/heads/main:refs/remotes/origin/main" + + - name: Verify release provenance + env: + PUBLISH_TARGET: ${{ inputs.publish_target || '' }} + run: python scripts/validate_release_provenance.py + publish: name: Publish reviewed distribution - needs: [build, smoke] + needs: [build, smoke, provenance] if: ${{ (github.event_name == 'push' && github.ref_type == 'tag') || github.event_name == 'workflow_dispatch' }} runs-on: ubuntu-latest timeout-minutes: 10 @@ -269,7 +293,7 @@ jobs: attest: name: Attest reviewed release - needs: [build, smoke] + needs: [build, smoke, provenance] if: ${{ (github.event_name == 'push' && github.ref_type == 'tag') || github.event_name == 'workflow_dispatch' }} runs-on: ubuntu-latest timeout-minutes: 10 @@ -303,7 +327,7 @@ jobs: release: name: Create GitHub Release - needs: [build, smoke, publish, attest] + needs: [build, smoke, provenance, publish, attest] if: ${{ github.event_name == 'push' && github.ref_type == 'tag' }} runs-on: ubuntu-latest timeout-minutes: 10 diff --git a/docs/releasing.md b/docs/releasing.md index 1989f47..dcff6d8 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -15,8 +15,15 @@ policy](https://github.com/basefoundry/base/blob/main/docs/ecosystem-policy.md). the wheel and sdist metadata, and `base_cli.__version__` reports the same value from a source checkout or from installed distribution metadata. -Production releases use a matching annotated-style tag such as `v0.1.0`. -The Package workflow rejects a tag that does not exactly match `v${VERSION}`. +Production releases use a matching annotated tag such as `v0.1.0`, created from +`main`. Lightweight tags, tags pointing at a commit outside `main`, forced tag +updates, and tags that do not exactly match `v${VERSION}` are rejected before +publication. The workflow fetches the complete trusted `main` history so an +ancestor check fails closed instead of relying on shallow checkout state. +The active default-branch ruleset also requires one approving pull-request +review, approval from someone other than the last pusher, strict up-to-date +status checks, and the policy, quality, runtime, and consumer checks listed in +the repository ruleset. Deletion and non-fast-forward updates are disabled. ## Validation workflow @@ -43,6 +50,13 @@ runs, GitHub's OIDC-backed `actions/attest` job records both build provenance and an SBOM attestation for the exact artifact digests; no PyPI token or other long-lived publish secret is used. +The provenance job runs after build and smoke validation and before any +publication, attestation, or GitHub Release write. It verifies the full source +SHA, annotated tag object, exact tag target, trusted `origin/main` ancestry, +non-shallow history, and push-event force/deletion flags. A TestPyPI dispatch +from a branch remains available for rehearsal, but still requires full history +and a full source SHA. + For a version tag, the same Package workflow creates a GitHub Release after the protected PyPI publication and attestations succeed. The release attaches the exact reviewed wheel, sdist, `SHA256SUMS`, `SBOM.spdx.json`, and @@ -174,9 +188,14 @@ publishing for this repository and workflow before the dispatch can upload. 'import base_cli; import importlib.metadata as m; assert base_cli.__version__ == m.version("base-cli"); print(base_cli.__version__)' ``` -The `pypi` GitHub environment must require approval and be configured with the -PyPI trusted publisher for `.github/workflows/package.yml`. No long-lived PyPI -token is stored in the repository. +The `pypi` GitHub environment requires approval, rejects self-review, disallows +administrator bypass, and is configured with the PyPI trusted publisher for +`.github/workflows/package.yml`. No long-lived PyPI token is stored in the +repository. Production publication therefore waits for an independent +reviewer until maintainer-capacity work in [#252](https://github.com/basefoundry/base-cli/issues/252) +adds one. If a temporary solo-maintainer exception is ever needed, it must be +time-bounded and record an owner, expiry, audit trail, and link to #252 before +an administrator changes the environment policy. ## Recovery diff --git a/scripts/validate_release_provenance.py b/scripts/validate_release_provenance.py new file mode 100644 index 0000000..5f600bd --- /dev/null +++ b/scripts/validate_release_provenance.py @@ -0,0 +1,141 @@ +#!/usr/bin/env python3 +"""Fail closed unless a publication comes from a reviewed main-line commit.""" + +from __future__ import annotations + +import argparse +import json +import os +import re +import subprocess +import sys +from pathlib import Path + +FULL_SHA = re.compile(r"^[0-9a-f]{40}$") +VALID_PUBLISH_TARGETS = {"", "testpypi", "pypi"} + + +def validate_release_provenance( + *, + event_name: str, + ref_type: str, + tag: str, + publish_target: str, + source_commit: str, + tag_type: str, + resolved_tag_commit: str, + main_reachable: bool, + repository_shallow: bool, + forced: bool = False, + deleted: bool = False, +) -> list[str]: + """Return violations for the source that is about to be published.""" + errors: list[str] = [] + if publish_target not in VALID_PUBLISH_TARGETS: + errors.append(f"unsupported publication target {publish_target!r}") + if not FULL_SHA.fullmatch(source_commit): + errors.append("reviewed source commit must be a full 40-character commit SHA") + if repository_shallow: + errors.append("release provenance cannot be verified from a shallow repository") + + production_release = (event_name == "push" and ref_type == "tag") or publish_target == "pypi" + tag_release = ref_type == "tag" + if production_release and ref_type != "tag": + errors.append("PyPI publication requires a version tag, not a branch or pull request ref") + + if tag_release: + if not tag.startswith("v") or tag == "v": + errors.append(f"release tag must be a v-prefixed version, got {tag!r}") + if tag_type != "tag": + errors.append("release tag must be an annotated tag; lightweight tags are rejected") + if not FULL_SHA.fullmatch(resolved_tag_commit): + errors.append("release tag must resolve to a full commit SHA") + elif resolved_tag_commit != source_commit: + errors.append( + f"release tag does not resolve to the reviewed source commit ({resolved_tag_commit} != {source_commit})" + ) + if not main_reachable: + errors.append("release tag commit is not reachable from the trusted origin/main history") + if forced: + errors.append("forced tag updates are rejected for release publication") + if deleted: + errors.append("deleted tag events are rejected for release publication") + + return errors + + +def _git(*args: str) -> tuple[int, str]: + completed = subprocess.run( + ["git", *args], + check=False, + capture_output=True, + text=True, + ) + return completed.returncode, completed.stdout.strip() + + +def _git_output(*args: str) -> str: + returncode, output = _git(*args) + return output if returncode == 0 else "" + + +def _read_event_flags(event_path: Path) -> tuple[bool, bool, list[str]]: + try: + payload = json.loads(event_path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + return False, False, [f"could not read the GitHub event payload: {exc}"] + return bool(payload.get("forced")), bool(payload.get("deleted")), [] + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--event-name", default=os.environ.get("GITHUB_EVENT_NAME", "")) + parser.add_argument("--event-path", type=Path, default=os.environ.get("GITHUB_EVENT_PATH", "")) + parser.add_argument("--ref-type", default=os.environ.get("GITHUB_REF_TYPE", "")) + parser.add_argument("--tag", default=os.environ.get("GITHUB_REF_NAME", "")) + parser.add_argument("--publish-target", default=os.environ.get("PUBLISH_TARGET", "")) + parser.add_argument("--source-commit", default=os.environ.get("GITHUB_SHA", "")) + parser.add_argument("--main-ref", default="refs/remotes/origin/main") + args = parser.parse_args() + + tag_type = "" + resolved_tag_commit = "" + main_reachable = False + if args.ref_type == "tag": + tag_ref = f"refs/tags/{args.tag}" + tag_type = _git_output("cat-file", "-t", tag_ref) + resolved_tag_commit = _git_output("rev-parse", "--verify", f"{tag_ref}^{{}}") + if resolved_tag_commit: + main_reachable = _git("merge-base", "--is-ancestor", resolved_tag_commit, args.main_ref)[0] == 0 + + forced = False + deleted = False + event_errors: list[str] = [] + if args.ref_type == "tag" or args.publish_target == "pypi": + if not args.event_path: + event_errors.append("GitHub event payload is required for release provenance validation") + else: + forced, deleted, event_errors = _read_event_flags(Path(args.event_path)) + + errors = event_errors + validate_release_provenance( + event_name=args.event_name, + ref_type=args.ref_type, + tag=args.tag, + publish_target=args.publish_target, + source_commit=args.source_commit, + tag_type=tag_type, + resolved_tag_commit=resolved_tag_commit, + main_reachable=main_reachable, + repository_shallow=_git_output("rev-parse", "--is-shallow-repository") == "true", + forced=forced, + deleted=deleted, + ) + if errors: + for error in errors: + print(f"release provenance validation failed: {error}", file=sys.stderr) + raise SystemExit(1) + print(f"Validated release provenance for {args.source_commit}") + + +if __name__ == "__main__": + main() diff --git a/tests/test_package_workflow.py b/tests/test_package_workflow.py index 185fe58..d5a75c5 100644 --- a/tests/test_package_workflow.py +++ b/tests/test_package_workflow.py @@ -22,3 +22,13 @@ def test_package_workflow_does_not_replace_published_release_assets() -> None: assert '--source-digest "$GITHUB_SHA"' in workflow assert '--source-ref "$GITHUB_REF"' in workflow assert "--clobber" not in workflow + + +def test_package_workflow_gates_writes_on_release_provenance() -> None: + workflow = (Path(__file__).resolve().parents[1] / ".github/workflows/package.yml").read_text(encoding="utf-8") + + assert "name: Verify reviewed release provenance" in workflow + assert 'git fetch --no-tags --prune origin "refs/heads/main:refs/remotes/origin/main"' in workflow + assert "python scripts/validate_release_provenance.py" in workflow + assert "needs: [build, smoke, provenance]" in workflow + assert "needs: [build, smoke, provenance, publish, attest]" in workflow diff --git a/tests/test_validate_release_provenance.py b/tests/test_validate_release_provenance.py new file mode 100644 index 0000000..ce93216 --- /dev/null +++ b/tests/test_validate_release_provenance.py @@ -0,0 +1,74 @@ +from __future__ import annotations + +import sys +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +from scripts.validate_release_provenance import validate_release_provenance + +SOURCE = "a" * 40 +TAG_COMMIT = SOURCE + + +def valid(**overrides: object) -> list[str]: + values: dict[str, object] = { + "event_name": "push", + "ref_type": "tag", + "tag": "v1.0.0", + "publish_target": "", + "source_commit": SOURCE, + "tag_type": "tag", + "resolved_tag_commit": TAG_COMMIT, + "main_reachable": True, + "repository_shallow": False, + } + values.update(overrides) + return validate_release_provenance(**values) # type: ignore[arg-type] + + +class ReleaseProvenanceValidationTests(unittest.TestCase): + def test_accepts_annotated_tag_on_main(self) -> None: + self.assertEqual(valid(), []) + + def test_rejects_lightweight_tag(self) -> None: + errors = valid(tag_type="commit") + self.assertTrue(any("annotated tag" in error for error in errors)) + + def test_rejects_unmerged_commit(self) -> None: + errors = valid(main_reachable=False) + self.assertTrue(any("not reachable" in error for error in errors)) + + def test_rejects_moved_or_mismatched_tag(self) -> None: + errors = valid(resolved_tag_commit="b" * 40) + self.assertTrue(any("does not resolve" in error for error in errors)) + + def test_rejects_forced_tag_update(self) -> None: + errors = valid(forced=True) + self.assertTrue(any("forced tag" in error for error in errors)) + + def test_rejects_shallow_history(self) -> None: + errors = valid(repository_shallow=True) + self.assertTrue(any("shallow" in error for error in errors)) + + def test_rejects_pypi_dispatch_from_a_branch(self) -> None: + errors = valid(event_name="workflow_dispatch", ref_type="branch", publish_target="pypi") + self.assertTrue(any("requires a version tag" in error for error in errors)) + + def test_allows_testpypi_branch_rehearsal_with_full_history(self) -> None: + self.assertEqual( + valid( + event_name="workflow_dispatch", + ref_type="branch", + tag="", + publish_target="testpypi", + tag_type="", + resolved_tag_commit="", + main_reachable=False, + ), + [], + ) + + +if __name__ == "__main__": + unittest.main()