From f2616214a8244c2d0da5358c3380a50671e810fa Mon Sep 17 00:00:00 2001 From: Jake Fineman Date: Sat, 5 Sep 2026 17:19:29 -0400 Subject: [PATCH 1/2] ci: codify release-on-tag + PyPI provenance pipeline Adds a three-job release.yml (verify -> publish -> release) triggered on v* tag push or workflow_dispatch (for backfilling an already-pushed tag), plus a standing release-drift.yml gate that fails loud whenever the tag, pyproject.toml version, PyPI latest version, GitHub Release existence, or PEP 740 attestation coverage disagree. Folds the prior tag-triggered publish job into the new release.yml (no second publisher). Closes the gap Instant a GA validator found: no GitHub Release for v2.1.0, PyPI a version behind at 2.0.0, and no provenance/attestation on the published package. Co-Authored-By: Claude Fable 5.1 --- .github/workflows/release-drift.yml | 55 +++++++ .github/workflows/release.yml | 200 ++++++++++++++--------- .gitignore | 10 ++ scripts/release/assert_version.py | 63 ++++++++ scripts/release/check_drift.py | 209 +++++++++++++++++++++++++ scripts/release/pypi_version_exists.py | 54 +++++++ 6 files changed, 520 insertions(+), 71 deletions(-) create mode 100644 .github/workflows/release-drift.yml create mode 100644 .gitignore create mode 100644 scripts/release/assert_version.py create mode 100644 scripts/release/check_drift.py create mode 100644 scripts/release/pypi_version_exists.py diff --git a/.github/workflows/release-drift.yml b/.github/workflows/release-drift.yml new file mode 100644 index 0000000..9201ffb --- /dev/null +++ b/.github/workflows/release-drift.yml @@ -0,0 +1,55 @@ +name: release-drift + +# Standing drift gate: fails loud the moment the tag, pyproject.toml version, +# PyPI's latest published version, GitHub Release existence, or PyPI +# attestation coverage disagree with each other. This is the mechanism behind +# Jake's 2026-09-05 decision -- "always codified workflows so we don't ever +# have drifts" -- it is the check that would have caught VER-001 (no GitHub +# Release for the current tag, PyPI a version behind) and SUPPLY-001 (no +# provenance/attestation on the published package) on day one instead of +# waiting for an external GA validator to find them. +# +# All comparison logic lives in scripts/release/check_drift.py so it is +# testable locally with zero CI round-trip: +# python3 scripts/release/check_drift.py +# +# Exit codes (from the script, passed straight through): +# 0 in sync -> job succeeds +# 1 drift detected -> job fails (this is a REAL, verified disagreement) +# 2 a source was unreadable -> job fails (network/API blip is NEVER +# silently treated as "in sync" -- that would hide a real drift behind +# a flaky read) + +on: + push: + branches: [main] + schedule: + - cron: "17 6 * * *" # daily, off the hour to avoid GitHub Actions' top-of-hour scheduling crunch + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: release-drift-${{ github.ref }} + cancel-in-progress: true + +jobs: + drift-check: + name: tag / pyproject / PyPI / release / attestation agreement + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + fetch-depth: 0 # need full tag history, not just the push's shallow clone + persist-credentials: false + + - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + with: + python-version: "3.12" + + - name: Run the drift check + env: + GH_TOKEN: ${{ github.token }} + run: python3 scripts/release/check_drift.py --repo-root . diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 13e6c85..9e7bb14 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,136 +1,194 @@ name: release -# Builds sdist + wheel and publishes to PyPI on `v*` tags using PyPI Trusted -# Publishing (OIDC) — no long-lived API token stored in this repo. On pull -# requests the same build runs as a dry-run: build, `twine check`, install -# the wheel into a throwaway venv, and import `Wave` from it. That dry-run -# job never touches PyPI (no `id-token` permission, no publish step). +# Codified release-on-tag pipeline for wave-sdk (PyPI: wave-sdk, PyPI Trusted +# Publishing, no stored token -- decision IGV-D-010: no per-repo publish +# secrets). Three jobs, each with its own minimal `permissions:`: # -# One-time setup before the first `v*` tag: register this repo + workflow as -# a Trusted Publisher on the `wave-sdk` PyPI project (see AGENTS.md / PR body -# for the exact fields — this workflow cannot self-register). +# verify -- checks out the exact tag, asserts tag == pyproject.toml +# version == wave_sdk.__version__ (fails loud on any mismatch), +# installs, runs the full pytest suite, builds sdist+wheel, +# and `twine check`s them. Nothing downstream runs on a red verify. +# publish -- id-token: write only. Publishes via PyPI Trusted Publishing +# (pypa/gh-action-pypi-publish, OIDC, NO API token) with PEP 740 +# attestations enabled. If PyPI already has this exact version +# (checked live against pypi.org before publishing), the publish +# step is skipped with a clear log line and the job still +# succeeds -- a re-run must be idempotent, not a hard failure. +# release -- contents: write only. Creates the GitHub Release for the tag +# with generated notes and the sdist+wheel attached. Idempotent: +# if the release already exists, it edits/uploads onto it +# instead of failing. +# +# Triggers on `v*` tag pushes AND `workflow_dispatch` with a `tag` input, so +# an existing tag (e.g. the current v2.1.0, pushed before this workflow +# existed) can be backfilled on demand: +# gh workflow run release.yml --repo wave-av/sdk-python --ref main -f tag=v2.1.0 +# +# Every `uses:` below is pinned to a 40-character commit SHA with the human +# version in a trailing comment -- a tag ref (`@v6`) is a mutable pointer an +# upstream maintainer (or an attacker who compromises their account) can +# repoint without your review ever seeing a new commit. +# +# One-time operator setup (this workflow cannot self-register): add this repo +# + workflow as a PyPI Trusted Publisher for the `wave-sdk` project. See the +# PR body for the exact pypi.org settings path. on: - pull_request: push: tags: ["v*"] workflow_dispatch: + inputs: + tag: + description: "Tag to verify/publish/release, e.g. v2.1.0 (backfill an existing tag)" + required: true + type: string permissions: contents: read concurrency: - group: release-${{ github.ref }} - cancel-in-progress: true + group: release-${{ github.event.inputs.tag || github.ref }} + cancel-in-progress: false jobs: - build: - name: build + dry-run check + verify: + name: verify (version match, tests, build) runs-on: ubuntu-latest - timeout-minutes: 10 + timeout-minutes: 15 + permissions: + contents: read + outputs: + tag: ${{ steps.resolve.outputs.tag }} + version: ${{ steps.resolve.outputs.version }} steps: + - name: Resolve target tag + id: resolve + env: + TAG_INPUT: ${{ github.event.inputs.tag }} + TAG_FROM_PUSH: ${{ github.ref_name }} + run: | + TAG="$TAG_INPUT" + if [ -z "$TAG" ]; then + TAG="$TAG_FROM_PUSH" + fi + # Strict allowlist: vN.N.N[-suffix] only -- this value flows into a + # `ref:` on the checkout steps below, so it is validated BEFORE use, + # not merely pattern-matched loosely. + if ! printf '%s' "$TAG" | grep -qE '^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.]+)?$'; then + echo "::error::tag '$TAG' does not match the strict vX.Y.Z[-suffix] pattern" + exit 1 + fi + echo "tag=$TAG" >> "$GITHUB_OUTPUT" + echo "version=${TAG#v}" >> "$GITHUB_OUTPUT" + echo "resolved tag=$TAG version=${TAG#v}" + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: + ref: refs/tags/${{ steps.resolve.outputs.tag }} persist-credentials: false - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: python-version: "3.12" - - name: Install build tooling + - name: Install the SDK with every extra its tests need run: | python -m pip install --upgrade pip + pip install -e ".[dev,realtime,x402]" pip install build twine + - name: Assert tag == pyproject.toml version == wave_sdk.__version__ + run: python3 scripts/release/assert_version.py "${{ steps.resolve.outputs.tag }}" + + - name: pytest (full suite, the checked-out tag) + run: python -m pytest -q + - name: Build sdist + wheel run: python -m build - name: twine check run: twine check dist/* - - name: Create fresh venv (no repo on sys.path) - run: python -m venv "$RUNNER_TEMP/dry-run" - - - name: Install the built wheel - run: | - WHEEL=$(ls dist/*.whl) - "$RUNNER_TEMP/dry-run/bin/pip" install --upgrade pip - "$RUNNER_TEMP/dry-run/bin/pip" install "$WHEEL" - - - name: Import check (installed wheel, run away from the repo) - working-directory: ${{ runner.temp }}/dry-run - run: | - bin/python -c " - from wave_sdk import Wave - import wave_sdk - print('wave_sdk', wave_sdk.__version__, 'imported OK from', wave_sdk.__file__) - print('Wave facade:', Wave) - " - - name: Upload dist uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: - name: dist + name: dist-${{ steps.resolve.outputs.tag }} path: dist/ - retention-days: 7 + retention-days: 14 publish: name: publish to PyPI - needs: build - if: startsWith(github.ref, 'refs/tags/v') + needs: verify runs-on: ubuntu-latest timeout-minutes: 10 environment: name: pypi url: https://pypi.org/project/wave-sdk/ permissions: - # OIDC token for PyPI Trusted Publishing — no API token secret needed. id-token: write contents: read steps: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: + ref: refs/tags/${{ needs.verify.outputs.tag }} persist-credentials: false - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: python-version: "3.12" - - name: Install build tooling - run: | - python -m pip install --upgrade pip - pip install build - - - name: Build sdist + wheel - run: python -m build + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: dist-${{ needs.verify.outputs.tag }} + path: dist - - name: Verify tag matches package version + - name: Check whether PyPI already has this exact version + id: pypi_check run: | - TAG_VERSION="${GITHUB_REF_NAME#v}" - PKG_VERSION=$(python -c "import tomllib; print(tomllib.load(open('pyproject.toml','rb'))['project']['version'])") - if [ "$TAG_VERSION" != "$PKG_VERSION" ]; then - echo "tag v$TAG_VERSION does not match pyproject.toml version $PKG_VERSION" - exit 1 - fi + set -e + RESULT=$(python3 scripts/release/pypi_version_exists.py "${{ needs.verify.outputs.version }}") + echo "exists=$RESULT" >> "$GITHUB_OUTPUT" - - name: Publish to PyPI (Trusted Publishing, OIDC) + - name: Publish to PyPI (Trusted Publishing, OIDC, PEP 740 attestations) + if: steps.pypi_check.outputs.exists == 'false' uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0 + with: + attestations: true + + - name: Skip publish (already on PyPI) + if: steps.pypi_check.outputs.exists == 'true' + run: echo "::notice::wave-sdk ${{ needs.verify.outputs.version }} is already on PyPI -- skipping publish, proceeding to release job" - - name: Post-publish verification (PyPI + fresh install) + release: + name: create GitHub Release + needs: [verify, publish] + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: write + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + ref: refs/tags/${{ needs.verify.outputs.tag }} + persist-credentials: false + + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: dist-${{ needs.verify.outputs.tag }} + path: dist + + - name: Create or update the GitHub Release for this tag (idempotent) + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ needs.verify.outputs.tag }} run: | - TAG_VERSION="${GITHUB_REF_NAME#v}" - for i in 1 2 3 4 5 6 7 8; do - AVAILABLE=$(pip index versions wave-sdk 2>/dev/null | grep -o "$TAG_VERSION" || true) - if [ -n "$AVAILABLE" ]; then break; fi - echo "waiting for PyPI to index wave-sdk==$TAG_VERSION (attempt $i)" - sleep 15 - done - python -m venv "$RUNNER_TEMP/verify" - "$RUNNER_TEMP/verify/bin/pip" install --upgrade pip - "$RUNNER_TEMP/verify/bin/pip" install "wave-sdk==$TAG_VERSION" - "$RUNNER_TEMP/verify/bin/python" -c " - from wave_sdk import Wave - import wave_sdk - assert wave_sdk.__version__ == '$TAG_VERSION', wave_sdk.__version__ - print('verified wave-sdk', wave_sdk.__version__, 'installed from PyPI, Wave facade OK') - " + set -e + if gh release view "$TAG" --repo "${{ github.repository }}" >/dev/null 2>&1; then + echo "::notice::release $TAG already exists -- uploading dist assets (clobber) instead of creating" + gh release upload "$TAG" dist/* --repo "${{ github.repository }}" --clobber + else + gh release create "$TAG" dist/* \ + --repo "${{ github.repository }}" \ + --title "wave-sdk $TAG" \ + --generate-notes + fi diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3d212d4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +.venv/ +__pycache__/ +*.pyc +dist/ +build/ +*.egg-info/ +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ +.DS_Store diff --git a/scripts/release/assert_version.py b/scripts/release/assert_version.py new file mode 100644 index 0000000..4f2f1a7 --- /dev/null +++ b/scripts/release/assert_version.py @@ -0,0 +1,63 @@ +#!/usr/bin/env python3 +"""Fail loud if the given tag does not match the project's declared version. + +Checks BOTH `pyproject.toml`'s `[project].version` AND `wave_sdk.__version__` +(imported from the checked-out tree, not an installed copy) against the tag. +Used by `release.yml`'s `verify` job right after checking out the tag -- this +is the single source of truth for "does this tag actually match the code", +and it is intentionally a plain script (not inlined YAML) so it can be run +and unit-tested locally without pushing a tag first. + +Usage: python3 scripts/release/assert_version.py v2.1.0 +Exit 0 if it matches, 1 with a clear message if it does not. +""" +from __future__ import annotations + +import sys +from pathlib import Path + +import tomllib + + +def main(argv: list[str]) -> int: + if len(argv) != 2: + print(f"usage: {argv[0]} ", file=sys.stderr) + return 2 + + tag = argv[1] + tag_version = tag[1:] if tag.startswith("v") else tag + repo_root = Path(__file__).resolve().parents[2] + + pyproject_path = repo_root / "pyproject.toml" + data = tomllib.loads(pyproject_path.read_text()) + pyproject_version = data["project"]["version"] + + sys.path.insert(0, str(repo_root)) + import wave_sdk # noqa: E402 (import after sys.path fix is intentional here) + + dunder_version = wave_sdk.__version__ + + print(f"tag : {tag} (version {tag_version})") + print(f"pyproject.toml : {pyproject_version}") + print(f"wave_sdk.__version__ : {dunder_version}") + + mismatches = [] + if tag_version != pyproject_version: + mismatches.append(f"tag {tag_version} != pyproject.toml {pyproject_version}") + if tag_version != dunder_version: + mismatches.append(f"tag {tag_version} != wave_sdk.__version__ {dunder_version}") + if pyproject_version != dunder_version: + mismatches.append(f"pyproject.toml {pyproject_version} != wave_sdk.__version__ {dunder_version}") + + if mismatches: + print("VERSION MISMATCH:", file=sys.stderr) + for m in mismatches: + print(f" - {m}", file=sys.stderr) + return 1 + + print("OK: tag, pyproject.toml, and wave_sdk.__version__ all agree") + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv)) diff --git a/scripts/release/check_drift.py b/scripts/release/check_drift.py new file mode 100644 index 0000000..70c7c73 --- /dev/null +++ b/scripts/release/check_drift.py @@ -0,0 +1,209 @@ +#!/usr/bin/env python3 +"""Release-drift detector for wave-av/sdk-python (PyPI package `wave-sdk`). + +Compares four sources of truth and fails loud the moment any two disagree: + 1. the latest git tag on the local checkout (or `--tag` override) + 2. the project version declared in `pyproject.toml` on the checked-out ref + 3. the latest version PyPI serves (`https://pypi.org/pypi/wave-sdk/json`) + 4. whether a GitHub Release exists for that tag (`gh api repos//releases/tags/`) + +It also checks PyPI's PEP 740 attestation/provenance field +(`urls[].provenance` in `https://pypi.org/pypi/wave-sdk//json`) for +the latest published version, and fails if no file carries one. + +Exit codes (checked by both workflows and safe to script against): + 0 everything agrees, release exists, attestation present + 1 drift detected (a real, verified disagreement) + 2 a source was unreadable (network error, bad JSON, git/gh failure) -- + an unreadable registry is NEVER treated as "in sync" + +Stdlib + `git`/`gh` CLI only. No third-party imports so this runs identically +in CI and on a laptop with nothing but Python 3.11+ and the GitHub CLI. +""" +from __future__ import annotations + +import argparse +import json +import subprocess +import sys +import urllib.error +import urllib.request +from pathlib import Path + +import tomllib + +PYPI_PROJECT = "wave-sdk" +GITHUB_REPO = "wave-av/sdk-python" +USER_AGENT = "wave-sdk-release-drift-check (+https://github.com/wave-av/sdk-python)" + + +class UnreadableError(Exception): + """A source could not be read at all (distinct from "read and disagrees").""" + + +def _http_get_json(url: str) -> dict: + req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT, "Accept": "application/json"}) + try: + with urllib.request.urlopen(req, timeout=20) as resp: + return json.loads(resp.read().decode("utf-8")) + except urllib.error.HTTPError as exc: + if exc.code == 404: + raise + raise UnreadableError(f"HTTP {exc.code} fetching {url}") from exc + except (urllib.error.URLError, TimeoutError, json.JSONDecodeError) as exc: + raise UnreadableError(f"could not read {url}: {exc}") from exc + + +def pypi_latest_version() -> str: + try: + data = _http_get_json(f"https://pypi.org/pypi/{PYPI_PROJECT}/json") + except urllib.error.HTTPError as exc: + raise UnreadableError(f"PyPI project page returned HTTP {exc.code}") from exc + try: + return data["info"]["version"] + except (KeyError, TypeError) as exc: + raise UnreadableError("PyPI JSON response missing info.version") from exc + + +def pypi_attestations(version: str) -> tuple[bool, list[str]]: + """Return (any_attested, [filenames missing provenance]).""" + try: + data = _http_get_json(f"https://pypi.org/pypi/{PYPI_PROJECT}/{version}/json") + except urllib.error.HTTPError as exc: + raise UnreadableError(f"PyPI release page for {version} returned HTTP {exc.code}") from exc + urls = data.get("urls") + if not isinstance(urls, list) or not urls: + raise UnreadableError(f"PyPI release page for {version} has no urls[] to check for provenance") + missing = [u.get("filename", "") for u in urls if not u.get("provenance")] + any_attested = any(u.get("provenance") for u in urls) + return any_attested, missing + + +def project_version(repo_root: Path) -> str: + pyproject = repo_root / "pyproject.toml" + try: + data = tomllib.loads(pyproject.read_text()) + except OSError as exc: + raise UnreadableError(f"could not read {pyproject}: {exc}") from exc + except tomllib.TOMLDecodeError as exc: + raise UnreadableError(f"could not parse {pyproject}: {exc}") from exc + try: + return data["project"]["version"] + except (KeyError, TypeError) as exc: + raise UnreadableError(f"{pyproject} has no [project].version") from exc + + +def latest_git_tag(repo_root: Path, override: str | None) -> str: + if override: + return override + try: + out = subprocess.run( + ["git", "-C", str(repo_root), "tag", "--list", "v*", "--sort=-v:refname"], + capture_output=True, + text=True, + check=True, + timeout=20, + ) + except (subprocess.CalledProcessError, OSError, subprocess.TimeoutExpired) as exc: + raise UnreadableError(f"could not list git tags: {exc}") from exc + tags = [t for t in out.stdout.splitlines() if t.strip()] + if not tags: + raise UnreadableError("no v*-pattern git tags found") + return tags[0] + + +def github_release_exists(tag: str) -> bool | None: + """True/False if the API answered, None if gh CLI itself is unavailable.""" + try: + result = subprocess.run( + ["gh", "api", f"repos/{GITHUB_REPO}/releases/tags/{tag}"], + capture_output=True, + text=True, + timeout=20, + ) + except (OSError, subprocess.TimeoutExpired) as exc: + raise UnreadableError(f"could not invoke gh CLI: {exc}") from exc + if result.returncode == 0: + return True + if "HTTP 404" in result.stderr or "Not Found" in result.stderr: + return False + raise UnreadableError(f"gh api releases/tags/{tag} failed: {result.stderr.strip()}") + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--repo-root", default=".", help="path to the sdk-python checkout") + parser.add_argument("--tag", default=None, help="override the git-tag source of truth (skips git tag --list)") + args = parser.parse_args() + repo_root = Path(args.repo_root).resolve() + + findings: list[str] = [] + drift = False + + try: + tag = latest_git_tag(repo_root, args.tag) + tag_version = tag[1:] if tag.startswith("v") else tag + print(f"[source] latest git tag : {tag} (version {tag_version})") + except UnreadableError as exc: + print(f"[UNREADABLE] git tag: {exc}", file=sys.stderr) + return 2 + + try: + pv = project_version(repo_root) + print(f"[source] pyproject.toml version : {pv}") + except UnreadableError as exc: + print(f"[UNREADABLE] pyproject.toml: {exc}", file=sys.stderr) + return 2 + + try: + pypi_v = pypi_latest_version() + print(f"[source] PyPI latest version : {pypi_v}") + except UnreadableError as exc: + print(f"[UNREADABLE] PyPI project json: {exc}", file=sys.stderr) + return 2 + + try: + released = github_release_exists(tag) + print(f"[source] GitHub Release for {tag} : {'exists' if released else 'MISSING'}") + except UnreadableError as exc: + print(f"[UNREADABLE] GitHub Release lookup: {exc}", file=sys.stderr) + return 2 + + try: + attested, missing = pypi_attestations(pypi_v) + if attested and not missing: + print(f"[source] PyPI {pypi_v} attestations : present on all files") + elif attested: + print(f"[source] PyPI {pypi_v} attestations : PARTIAL, missing on {missing}") + else: + print(f"[source] PyPI {pypi_v} attestations : NONE (urls[].provenance is null on every file)") + except UnreadableError as exc: + print(f"[UNREADABLE] PyPI attestation lookup: {exc}", file=sys.stderr) + return 2 + + if tag_version != pv: + findings.append(f"DRIFT: git tag {tag} (version {tag_version}) != pyproject.toml version {pv}") + drift = True + if pypi_v != tag_version: + findings.append(f"DRIFT: PyPI latest ({pypi_v}) != latest tag version ({tag_version})") + drift = True + if not released: + findings.append(f"DRIFT: no GitHub Release exists for tag {tag}") + drift = True + if not attested or missing: + findings.append(f"DRIFT: PyPI {pypi_v} is missing PEP 740 attestation/provenance on: {missing or 'all files'}") + drift = True + + print() + if drift: + print("RESULT: DRIFT DETECTED") + for f in findings: + print(f" - {f}") + return 1 + + print("RESULT: in sync (tag, pyproject, PyPI, GitHub Release, and attestations all agree)") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/release/pypi_version_exists.py b/scripts/release/pypi_version_exists.py new file mode 100644 index 0000000..bf72bb2 --- /dev/null +++ b/scripts/release/pypi_version_exists.py @@ -0,0 +1,54 @@ +#!/usr/bin/env python3 +"""Check whether PyPI already has an exact version of `wave-sdk`. + +Used by `release.yml`'s `publish` job to decide whether to skip the publish +step (idempotent re-runs of `workflow_dispatch` against an already-published +tag must not fail, and must not attempt to re-upload an existing file -- +PyPI itself rejects that). Prints `true` or `false` on stdout so a workflow +step can capture it directly into `$GITHUB_OUTPUT`. + +Exit 0 whether the version exists or not (that's a successful check). Exit 2 +only if PyPI itself could not be read (never treat "unreadable" as "does not +exist" -- that would make an outage silently re-attempt a publish PyPI may +actually already have). + +Usage: python3 scripts/release/pypi_version_exists.py 2.1.0 +""" +from __future__ import annotations + +import json +import sys +import urllib.error +import urllib.request + +PYPI_PROJECT = "wave-sdk" +USER_AGENT = "wave-sdk-release-check (+https://github.com/wave-av/sdk-python)" + + +def main(argv: list[str]) -> int: + if len(argv) != 2: + print(f"usage: {argv[0]} ", file=sys.stderr) + return 2 + + version = argv[1] + url = f"https://pypi.org/pypi/{PYPI_PROJECT}/{version}/json" + req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT, "Accept": "application/json"}) + + try: + with urllib.request.urlopen(req, timeout=20) as resp: + json.loads(resp.read().decode("utf-8")) + print("true") + return 0 + except urllib.error.HTTPError as exc: + if exc.code == 404: + print("false") + return 0 + print(f"UNREADABLE: PyPI returned HTTP {exc.code} for {url}", file=sys.stderr) + return 2 + except (urllib.error.URLError, TimeoutError, json.JSONDecodeError) as exc: + print(f"UNREADABLE: could not read {url}: {exc}", file=sys.stderr) + return 2 + + +if __name__ == "__main__": + sys.exit(main(sys.argv)) From 2eef32a91e18b17935ec18773800fb41de191302 Mon Sep 17 00:00:00 2001 From: Jake Fineman Date: Sat, 5 Sep 2026 18:54:04 -0400 Subject: [PATCH 2/2] ci(release): validate dispatch tag against main ancestry and pin checkouts to the resolved sha Co-Authored-By: Claude Fable 5.1 --- .github/workflows/release.yml | 128 ++++++++++++++++++++++++++-------- 1 file changed, 99 insertions(+), 29 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9e7bb14..aaa0471 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -2,9 +2,18 @@ name: release # Codified release-on-tag pipeline for wave-sdk (PyPI: wave-sdk, PyPI Trusted # Publishing, no stored token -- decision IGV-D-010: no per-repo publish -# secrets). Three jobs, each with its own minimal `permissions:`: +# secrets). Four jobs, each with its own minimal `permissions:`: # -# verify -- checks out the exact tag, asserts tag == pyproject.toml +# resolve-ref -- resolves + strictly validates the tag (push ref or +# dispatch input) BEFORE it reaches any git/python/gh command as +# text: format (v), existence (git rev-parse), and trust +# (the tag's commit must be an ancestor of origin/main) -- every +# later job checks out that commit BY SHA, not by the (mutable) +# tag name. A workflow_dispatch `tag` input is +# attacker-influencable text; requiring ancestry means every +# downstream job only ever builds/publishes/releases code that +# was already merged and reviewed on the default branch. +# verify -- checks out the resolved sha, asserts tag == pyproject.toml # version == wave_sdk.__version__ (fails loud on any mismatch), # installs, runs the full pytest suite, builds sdist+wheel, # and `twine check`s them. Nothing downstream runs on a red verify. @@ -51,42 +60,103 @@ concurrency: cancel-in-progress: false jobs: - verify: - name: verify (version match, tests, build) + # --------------------------------------------------------------------------- + # Resolve + strictly validate the tag before it reaches any command. A + # dispatch input is attacker-influencable text; a full-anchored regex (not a + # glob) refuses anything that isn't exactly `v`. Beyond format, this + # job also proves the tag's commit EXISTS and is an ANCESTOR of origin/main + # before any downstream job checks it out. Outputs both `tag` (the name, for + # assert_version.py/gh release) and `sha` (the validated commit, used for + # every downstream checkout) -- never interpolated into a shell body, only + # passed via env:/outputs. + # --------------------------------------------------------------------------- + resolve-ref: + name: Resolve release tag runs-on: ubuntu-latest - timeout-minutes: 15 + timeout-minutes: 5 permissions: contents: read outputs: tag: ${{ steps.resolve.outputs.tag }} - version: ${{ steps.resolve.outputs.version }} + sha: ${{ steps.resolve.outputs.sha }} steps: - - name: Resolve target tag + # Full history + all tags -- needed so `git rev-parse`/`git merge-base` + # below can see the tag and `origin/main`. No `ref:` override: this + # checks out whatever triggered the run (the pushed tag, or the branch a + # dispatch was run from) -- never the untrusted dispatch input -- and no + # step here executes anything from that tree (metadata reads only). + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Resolve and validate the tag to release id: resolve env: - TAG_INPUT: ${{ github.event.inputs.tag }} - TAG_FROM_PUSH: ${{ github.ref_name }} + DISPATCH_TAG: ${{ github.event.inputs.tag }} + PUSH_TAG: ${{ github.ref_name }} run: | - TAG="$TAG_INPUT" - if [ -z "$TAG" ]; then - TAG="$TAG_FROM_PUSH" + set -euo pipefail + TAG="${DISPATCH_TAG:-$PUSH_TAG}" + + # 1. Format -- full-anchored regex, not a glob -- refuses anything + # that isn't exactly `v`. This value flows into a `ref:` + # on downstream checkout steps (as the resolved sha, never the + # tag text itself), so it is validated BEFORE use. + if [[ ! "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.]+)?$ ]]; then + echo "::error::refusing to process ref '$TAG' — expected v" + exit 1 + fi + + # 2. Existence -- the tag must resolve to a real commit object in + # THIS repo, not just text that happens to look right. + if ! SHA="$(git rev-parse --verify --quiet "refs/tags/${TAG}^{commit}")"; then + echo "::error::tag '$TAG' does not exist in this repository (or does not point at a commit)" + exit 1 fi - # Strict allowlist: vN.N.N[-suffix] only -- this value flows into a - # `ref:` on the checkout steps below, so it is validated BEFORE use, - # not merely pattern-matched loosely. - if ! printf '%s' "$TAG" | grep -qE '^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.]+)?$'; then - echo "::error::tag '$TAG' does not match the strict vX.Y.Z[-suffix] pattern" + + # 3. Trust -- a `workflow_dispatch` input is attacker-influencable + # text, and a dispatch run's ambient GITHUB_REF/Actions-cache + # scope is whatever branch it was dispatched on (normally + # `main`), even though the CODE this workflow goes on to execute + # (pytest, build, twine, PyPI publish) is the resolved tag's + # tree. Format-validity alone does not prove that tree was ever + # reviewed -- a tag pushed off an arbitrary/unmerged branch + # passes the regex above just as easily. Requiring the tag's + # commit to be an ANCESTOR of origin/main means every later job + # only ever runs code that was already merged (and reviewed) on + # the default branch -- never a bespoke, unmerged payload + # smuggled in via a crafted tag. + if ! git merge-base --is-ancestor "$SHA" origin/main; then + echo "::error::tag '$TAG' (commit $SHA) is not reachable from origin/main — refusing to build/publish/release an unmerged or unrecognized ref" exit 1 fi + + echo "resolved tag: $TAG -> $SHA (verified: v shape, exists, ancestor of origin/main)" echo "tag=$TAG" >> "$GITHUB_OUTPUT" - echo "version=${TAG#v}" >> "$GITHUB_OUTPUT" - echo "resolved tag=$TAG version=${TAG#v}" + echo "sha=$SHA" >> "$GITHUB_OUTPUT" + verify: + name: verify (version match, tests, build) + needs: [resolve-ref] + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: read + outputs: + version: ${{ steps.version.outputs.version }} + steps: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: - ref: refs/tags/${{ steps.resolve.outputs.tag }} + ref: ${{ needs.resolve-ref.outputs.sha }} persist-credentials: false + - name: Derive version from the resolved tag + id: version + env: + TAG: ${{ needs.resolve-ref.outputs.tag }} + run: echo "version=${TAG#v}" >> "$GITHUB_OUTPUT" + - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: python-version: "3.12" @@ -98,7 +168,7 @@ jobs: pip install build twine - name: Assert tag == pyproject.toml version == wave_sdk.__version__ - run: python3 scripts/release/assert_version.py "${{ steps.resolve.outputs.tag }}" + run: python3 scripts/release/assert_version.py "${{ needs.resolve-ref.outputs.tag }}" - name: pytest (full suite, the checked-out tag) run: python -m pytest -q @@ -112,13 +182,13 @@ jobs: - name: Upload dist uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: - name: dist-${{ steps.resolve.outputs.tag }} + name: dist-${{ needs.resolve-ref.outputs.tag }} path: dist/ retention-days: 14 publish: name: publish to PyPI - needs: verify + needs: [resolve-ref, verify] runs-on: ubuntu-latest timeout-minutes: 10 environment: @@ -130,7 +200,7 @@ jobs: steps: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: - ref: refs/tags/${{ needs.verify.outputs.tag }} + ref: ${{ needs.resolve-ref.outputs.sha }} persist-credentials: false - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 @@ -139,7 +209,7 @@ jobs: - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: - name: dist-${{ needs.verify.outputs.tag }} + name: dist-${{ needs.resolve-ref.outputs.tag }} path: dist - name: Check whether PyPI already has this exact version @@ -161,7 +231,7 @@ jobs: release: name: create GitHub Release - needs: [verify, publish] + needs: [resolve-ref, publish] runs-on: ubuntu-latest timeout-minutes: 10 permissions: @@ -169,18 +239,18 @@ jobs: steps: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: - ref: refs/tags/${{ needs.verify.outputs.tag }} + ref: ${{ needs.resolve-ref.outputs.sha }} persist-credentials: false - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: - name: dist-${{ needs.verify.outputs.tag }} + name: dist-${{ needs.resolve-ref.outputs.tag }} path: dist - name: Create or update the GitHub Release for this tag (idempotent) env: GH_TOKEN: ${{ github.token }} - TAG: ${{ needs.verify.outputs.tag }} + TAG: ${{ needs.resolve-ref.outputs.tag }} run: | set -e if gh release view "$TAG" --repo "${{ github.repository }}" >/dev/null 2>&1; then