From 53225245c5681d1306d2e894e34d0898222571bc Mon Sep 17 00:00:00 2001 From: abrichr Date: Wed, 26 Aug 2026 15:24:50 -0400 Subject: [PATCH 01/10] ci: publish GitHub releases through release app --- .github/workflows/release-and-publish.yml | 18 +++++++++++++++--- tests/test_release_lock.py | 14 ++++++++++++-- 2 files changed, 27 insertions(+), 5 deletions(-) diff --git a/.github/workflows/release-and-publish.yml b/.github/workflows/release-and-publish.yml index 7f11c0a09..aa99aaa9b 100644 --- a/.github/workflows/release-and-publish.yml +++ b/.github/workflows/release-and-publish.yml @@ -2,7 +2,8 @@ name: Release and PyPI Publish # A release starts from a version, changelog, and lockfile that reached the # exact protected main branch through review. The release App can create only -# the annotated tag. It cannot push a version commit or any other main commit. +# the annotated tag and its matching GitHub Release. It cannot push a version +# commit or any other main commit. # # A protected v* tag starts the build, attestation, PyPI Trusted Publishing, # GitHub Release, and publication-verification jobs. Rerun that exact tag run @@ -262,7 +263,7 @@ jobs: if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') runs-on: ubuntu-latest permissions: - contents: write + contents: read steps: - name: Checkout the exact release tag @@ -282,9 +283,20 @@ jobs: name: release-dists-${{ github.ref_name }} path: dist/ + - name: Create a repository-scoped release App token for GitHub publication + id: release-app + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + app-id: ${{ vars.OPENADAPT_RELEASE_APP_ID }} + private-key: ${{ secrets.OPENADAPT_RELEASE_APP_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + repositories: ${{ github.event.repository.name }} + permission-contents: write + permission-metadata: read + - name: Publish the GitHub Release and exact artifacts env: - GH_TOKEN: ${{ github.token }} + GH_TOKEN: ${{ steps.release-app.outputs.token }} RELEASE_TAG: ${{ github.ref_name }} run: | set -euo pipefail diff --git a/tests/test_release_lock.py b/tests/test_release_lock.py index e03a19d27..00e5e7d1c 100644 --- a/tests/test_release_lock.py +++ b/tests/test_release_lock.py @@ -107,7 +107,7 @@ def test_release_workflow_pins_actions_and_separates_permissions(): "contents": "read", "id-token": "write", } - assert jobs["publish-github"]["permissions"] == {"contents": "write"} + assert jobs["publish-github"]["permissions"] == {"contents": "read"} assert jobs["verify-publication"]["permissions"] == { "contents": "read", "issues": "write", @@ -189,12 +189,22 @@ def test_release_workflow_publishes_from_the_exact_app_tag_with_oidc(): assert publish["with"] == {"skip-existing": True} publish_steps = jobs["publish-github"]["steps"] + app = next(step for step in publish_steps if step.get("id") == "release-app") + assert app["uses"].startswith("actions/create-github-app-token@") + assert app["with"] == { + "app-id": "${{ vars.OPENADAPT_RELEASE_APP_ID }}", + "private-key": "${{ secrets.OPENADAPT_RELEASE_APP_PRIVATE_KEY }}", + "owner": "${{ github.repository_owner }}", + "repositories": "${{ github.event.repository.name }}", + "permission-contents": "write", + "permission-metadata": "read", + } publish = next( step for step in publish_steps if step["name"] == "Publish the GitHub Release and exact artifacts" ) - assert publish["env"]["GH_TOKEN"] == "${{ github.token }}" + assert publish["env"]["GH_TOKEN"] == "${{ steps.release-app.outputs.token }}" assert publish["env"]["RELEASE_TAG"] == "${{ github.ref_name }}" assert "gh release create" in publish["run"] assert "--verify-tag" in publish["run"] From 2f3a2c518459b4e95792f723b8955d24577b2822 Mon Sep 17 00:00:00 2001 From: abrichr Date: Wed, 26 Aug 2026 16:06:24 -0400 Subject: [PATCH 02/10] ci: protect launcher release publication --- .github/workflows/release-and-publish.yml | 1 + tests/test_release_lock.py | 1 + 2 files changed, 2 insertions(+) diff --git a/.github/workflows/release-and-publish.yml b/.github/workflows/release-and-publish.yml index aa99aaa9b..688bdd7df 100644 --- a/.github/workflows/release-and-publish.yml +++ b/.github/workflows/release-and-publish.yml @@ -262,6 +262,7 @@ jobs: needs: [build-and-attest, publish-pypi] if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') runs-on: ubuntu-latest + environment: pypi permissions: contents: read diff --git a/tests/test_release_lock.py b/tests/test_release_lock.py index 00e5e7d1c..268fdcdac 100644 --- a/tests/test_release_lock.py +++ b/tests/test_release_lock.py @@ -188,6 +188,7 @@ def test_release_workflow_publishes_from_the_exact_app_tag_with_oidc(): assert publish["uses"].startswith("pypa/gh-action-pypi-publish@") assert publish["with"] == {"skip-existing": True} + assert jobs["publish-github"]["environment"] == "pypi" publish_steps = jobs["publish-github"]["steps"] app = next(step for step in publish_steps if step.get("id") == "release-app") assert app["uses"].startswith("actions/create-github-app-token@") From 63fc435787811b96829957f06953242496513178 Mon Sep 17 00:00:00 2001 From: abrichr Date: Wed, 26 Aug 2026 16:30:05 -0400 Subject: [PATCH 03/10] fix: make launcher releases immutable --- .github/workflows/release-and-publish.yml | 103 ++++++++++++-- scripts/verify_github_release.py | 159 ++++++++++++++++++++++ tests/test_release_lock.py | 59 ++++++-- tests/test_verify_github_release.py | 141 +++++++++++++++++++ 4 files changed, 441 insertions(+), 21 deletions(-) create mode 100644 scripts/verify_github_release.py create mode 100644 tests/test_verify_github_release.py diff --git a/.github/workflows/release-and-publish.yml b/.github/workflows/release-and-publish.yml index 688bdd7df..09dc1326a 100644 --- a/.github/workflows/release-and-publish.yml +++ b/.github/workflows/release-and-publish.yml @@ -34,7 +34,7 @@ jobs: runs-on: ubuntu-latest environment: release-identity permissions: - contents: write + contents: read steps: - name: Create a repository-scoped release App token @@ -129,6 +129,14 @@ jobs: set -euo pipefail git config user.name "openadapt-release[bot]" git config user.email "openadapt-release[bot]@users.noreply.github.com" + + git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main + current_main="$(git rev-parse refs/remotes/origin/main)" + if [ "$current_main" != "$GITHUB_SHA" ]; then + echo "main advanced before the tag push. main=$current_main candidate=$GITHUB_SHA." >&2 + exit 1 + fi + git tag -a "$RELEASE_TAG" "$GITHUB_SHA" -m "OpenAdapt ${RELEASE_TAG#v}" git push origin "refs/tags/$RELEASE_TAG" @@ -299,6 +307,7 @@ jobs: env: GH_TOKEN: ${{ steps.release-app.outputs.token }} RELEASE_TAG: ${{ github.ref_name }} + EXPECTED_AUTHOR: openadapt-release[bot] run: | set -euo pipefail python - "$RELEASE_TAG" > /tmp/release-notes.md <<'PY' @@ -319,23 +328,97 @@ jobs: print(match.group("body").strip()) PY - if gh release view "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then - gh release edit "$RELEASE_TAG" \ - --repo "$GITHUB_REPOSITORY" \ - --title "$RELEASE_TAG" \ - --notes-file /tmp/release-notes.md \ - --latest - else + git fetch --force origin "refs/tags/$RELEASE_TAG:refs/tags/$RELEASE_TAG" + tag_commit="$(git rev-list -n 1 "refs/tags/$RELEASE_TAG")" + if [ "$tag_commit" != "$GITHUB_SHA" ]; then + echo "The remote release tag does not resolve to the event commit." >&2 + exit 1 + fi + + release_url="$GITHUB_API_URL/repos/$GITHUB_REPOSITORY/releases/tags/$RELEASE_TAG" + release_status="$(curl --silent --show-error \ + --output /tmp/github-release.json \ + --write-out '%{http_code}' \ + --header "Accept: application/vnd.github+json" \ + --header "Authorization: Bearer $GH_TOKEN" \ + --header "X-GitHub-Api-Version: 2022-11-28" \ + "$release_url")" + + if [ "$release_status" = "404" ]; then gh release create "$RELEASE_TAG" \ + dist/*.whl dist/*.tar.gz \ --repo "$GITHUB_REPOSITORY" \ --verify-tag \ --title "$RELEASE_TAG" \ --notes-file /tmp/release-notes.md \ --latest + release_status="$(curl --silent --show-error \ + --output /tmp/github-release.json \ + --write-out '%{http_code}' \ + --header "Accept: application/vnd.github+json" \ + --header "Authorization: Bearer $GH_TOKEN" \ + --header "X-GitHub-Api-Version: 2022-11-28" \ + "$release_url")" + elif [ "$release_status" = "200" ]; then + python scripts/verify_github_release.py \ + --metadata /tmp/github-release.json \ + --tag "$RELEASE_TAG" \ + --author "$EXPECTED_AUTHOR" \ + --expected-dir dist \ + --allow-missing \ + --missing-output /tmp/missing-release-assets.txt + + mkdir /tmp/existing-github-release-assets + asset_count="$(python -c \ + 'import json; print(len(json.load(open("/tmp/github-release.json"))["assets"]))')" + if [ "$asset_count" -gt 0 ]; then + gh release download "$RELEASE_TAG" \ + --repo "$GITHUB_REPOSITORY" \ + --dir /tmp/existing-github-release-assets + fi + python scripts/verify_github_release.py \ + --metadata /tmp/github-release.json \ + --tag "$RELEASE_TAG" \ + --author "$EXPECTED_AUTHOR" \ + --expected-dir dist \ + --downloaded-dir /tmp/existing-github-release-assets \ + --allow-missing \ + --missing-output /tmp/missing-release-assets.txt + + while IFS= read -r missing_asset; do + gh release upload "$RELEASE_TAG" "dist/$missing_asset" \ + --repo "$GITHUB_REPOSITORY" + done < /tmp/missing-release-assets.txt + + release_status="$(curl --silent --show-error \ + --output /tmp/github-release.json \ + --write-out '%{http_code}' \ + --header "Accept: application/vnd.github+json" \ + --header "Authorization: Bearer $GH_TOKEN" \ + --header "X-GitHub-Api-Version: 2022-11-28" \ + "$release_url")" fi - gh release upload "$RELEASE_TAG" dist/*.whl dist/*.tar.gz \ + if [ "$release_status" != "200" ]; then + echo "GitHub returned HTTP $release_status for the exact release." >&2 + exit 1 + fi + + python scripts/verify_github_release.py \ + --metadata /tmp/github-release.json \ + --tag "$RELEASE_TAG" \ + --author "$EXPECTED_AUTHOR" \ + --expected-dir dist + + mkdir /tmp/github-release-assets + gh release download "$RELEASE_TAG" \ --repo "$GITHUB_REPOSITORY" \ - --clobber + --dir /tmp/github-release-assets + python scripts/verify_github_release.py \ + --metadata /tmp/github-release.json \ + --tag "$RELEASE_TAG" \ + --author "$EXPECTED_AUTHOR" \ + --expected-dir dist \ + --downloaded-dir /tmp/github-release-assets verify-publication: needs: [build-and-attest, publish-pypi, publish-github] diff --git a/scripts/verify_github_release.py b/scripts/verify_github_release.py new file mode 100644 index 000000000..db71bd96f --- /dev/null +++ b/scripts/verify_github_release.py @@ -0,0 +1,159 @@ +#!/usr/bin/env python3 +"""Verify that a GitHub Release is the exact immutable release candidate.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +from pathlib import Path +from typing import Any + + +def _sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as stream: + for chunk in iter(lambda: stream.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def _artifact_files(directory: Path, *, allow_empty: bool = False) -> dict[str, Path]: + if not directory.is_dir(): + raise ValueError(f"artifact directory does not exist: {directory}") + + files = { + path.name: path + for path in directory.iterdir() + if path.is_file() and (path.suffix == ".whl" or path.name.endswith(".tar.gz")) + } + if not files and not allow_empty: + raise ValueError(f"artifact directory has no wheel or sdist: {directory}") + + unexpected = sorted( + path.name + for path in directory.iterdir() + if path.is_file() and path.name not in files + ) + if unexpected: + raise ValueError( + f"artifact directory contains unexpected files: {', '.join(unexpected)}" + ) + return files + + +def verify_release( + document: dict[str, Any], + *, + expected_tag: str, + expected_author: str, + expected_dir: Path, + downloaded_dir: Path | None = None, + allow_missing: bool = False, +) -> list[str]: + if document.get("tag_name") != expected_tag: + raise ValueError( + f"release tag mismatch: expected {expected_tag!r}, " + f"found {document.get('tag_name')!r}" + ) + if document.get("draft") is not False: + raise ValueError("the GitHub Release is a draft") + if document.get("prerelease") is not False: + raise ValueError("the GitHub Release is a prerelease") + + author = document.get("author") + actual_author = author.get("login") if isinstance(author, dict) else None + if actual_author != expected_author: + raise ValueError( + f"release author mismatch: expected {expected_author!r}, " + f"found {actual_author!r}" + ) + + expected = _artifact_files(expected_dir) + raw_assets = document.get("assets") + if not isinstance(raw_assets, list): + raise ValueError("release assets are missing or invalid") + + assets: dict[str, dict[str, Any]] = {} + for raw_asset in raw_assets: + if not isinstance(raw_asset, dict) or not isinstance( + raw_asset.get("name"), str + ): + raise ValueError("release asset metadata is invalid") + name = raw_asset["name"] + if name in assets: + raise ValueError(f"release has duplicate asset name: {name}") + assets[name] = raw_asset + + expected_names = set(expected) + actual_names = set(assets) + missing = sorted(expected_names - actual_names) + unexpected = sorted(actual_names - expected_names) + if unexpected: + raise ValueError( + f"release asset set mismatch (unexpected: {', '.join(unexpected)})" + ) + if missing and not allow_missing: + raise ValueError(f"release asset set mismatch (missing: {', '.join(missing)})") + + expected_digests: dict[str, str] = {} + for name in sorted(actual_names): + path = expected[name] + digest = _sha256(path) + expected_digests[name] = digest + asset = assets[name] + if asset.get("size") != path.stat().st_size: + raise ValueError(f"release asset size mismatch: {name}") + if asset.get("digest") != f"sha256:{digest}": + raise ValueError(f"release asset digest mismatch: {name}") + + if downloaded_dir is None: + return missing + + downloaded = _artifact_files(downloaded_dir, allow_empty=allow_missing) + required_downloads = actual_names if allow_missing else expected_names + if set(downloaded) != required_downloads: + raise ValueError("downloaded release asset set does not match the candidate") + for name, path in downloaded.items(): + if path.stat().st_size != expected[name].stat().st_size: + raise ValueError(f"downloaded release asset size mismatch: {name}") + if _sha256(path) != expected_digests[name]: + raise ValueError(f"downloaded release asset bytes mismatch: {name}") + return missing + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--metadata", type=Path, required=True) + parser.add_argument("--tag", required=True) + parser.add_argument("--author", required=True) + parser.add_argument("--expected-dir", type=Path, required=True) + parser.add_argument("--downloaded-dir", type=Path) + parser.add_argument("--allow-missing", action="store_true") + parser.add_argument("--missing-output", type=Path) + args = parser.parse_args() + + try: + document = json.loads(args.metadata.read_text(encoding="utf-8")) + if not isinstance(document, dict): + raise ValueError("release metadata must be a JSON object") + missing = verify_release( + document, + expected_tag=args.tag, + expected_author=args.author, + expected_dir=args.expected_dir, + downloaded_dir=args.downloaded_dir, + allow_missing=args.allow_missing, + ) + if args.missing_output is not None: + args.missing_output.write_text( + "".join(f"{name}\n" for name in missing), encoding="utf-8" + ) + except (OSError, json.JSONDecodeError, ValueError) as exc: + parser.exit(1, f"GitHub Release verification failed: {exc}\n") + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_release_lock.py b/tests/test_release_lock.py index 268fdcdac..87baeb8f6 100644 --- a/tests/test_release_lock.py +++ b/tests/test_release_lock.py @@ -97,7 +97,7 @@ def test_release_workflow_pins_actions_and_separates_permissions(): assert document["permissions"] == {"contents": "read"} jobs = document["jobs"] - assert jobs["create-release-tag"]["permissions"] == {"contents": "write"} + assert jobs["create-release-tag"]["permissions"] == {"contents": "read"} assert jobs["build-and-attest"]["permissions"] == { "contents": "read", "id-token": "write", @@ -132,9 +132,7 @@ def test_release_workflow_app_creates_only_an_exact_reviewed_tag(): create = jobs["create-release-tag"] assert create["environment"] == "release-identity" assert "github.event_name == 'workflow_dispatch'" in create["if"] - app = next( - step for step in create["steps"] if step.get("id") == "release-app" - ) + app = next(step for step in create["steps"] if step.get("id") == "release-app") assert app["uses"].startswith("actions/create-github-app-token@") assert app["with"] == { "app-id": "${{ vars.OPENADAPT_RELEASE_APP_ID }}", @@ -144,9 +142,7 @@ def test_release_workflow_app_creates_only_an_exact_reviewed_tag(): "permission-contents": "write", } - candidate = next( - step for step in create["steps"] if step.get("id") == "candidate" - ) + candidate = next(step for step in create["steps"] if step.get("id") == "candidate") assert 'GITHUB_REF" != "refs/heads/main' in candidate["run"] assert 'current_main" != "$GITHUB_SHA' in candidate["run"] assert 'REQUESTED_VERSION" != "$project_version' in candidate["run"] @@ -158,10 +154,40 @@ def test_release_workflow_app_creates_only_an_exact_reviewed_tag(): for step in create["steps"] if step["name"] == "Create and push only the annotated release tag" ) + refresh_index = tag["run"].index( + "git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main" + ) + compare_index = tag["run"].index('current_main" != "$GITHUB_SHA') + create_index = tag["run"].index('git tag -a "$RELEASE_TAG" "$GITHUB_SHA"') + push_index = tag["run"].index('git push origin "refs/tags/$RELEASE_TAG"') + assert refresh_index < compare_index < create_index < push_index assert 'git tag -a "$RELEASE_TAG" "$GITHUB_SHA"' in tag["run"] assert 'git push origin "refs/tags/$RELEASE_TAG"' in tag["run"] +def test_release_workflow_rechecks_main_immediately_before_the_app_tag_push(): + workflow_path = ROOT / ".github/workflows/release-and-publish.yml" + document = yaml.safe_load(workflow_path.read_text(encoding="utf-8")) + create = document["jobs"]["create-release-tag"] + + candidate = next(step for step in create["steps"] if step.get("id") == "candidate") + tag = next( + step + for step in create["steps"] + if step["name"] == "Create and push only the annotated release tag" + ) + + # The candidate check alone is not sufficient. main can advance before the + # separate tag-push step starts, so that step must get and compare main too. + assert "git fetch --no-tags origin" in candidate["run"] + assert "git fetch --no-tags origin" in tag["run"] + assert 'current_main="$(git rev-parse refs/remotes/origin/main)"' in tag["run"] + assert 'current_main" != "$GITHUB_SHA' in tag["run"] + assert tag["run"].rindex('current_main" != "$GITHUB_SHA') < tag["run"].index( + 'git push origin "refs/tags/$RELEASE_TAG"' + ) + + def test_release_workflow_publishes_from_the_exact_app_tag_with_oidc(): workflow_path = ROOT / ".github/workflows/release-and-publish.yml" document = yaml.safe_load(workflow_path.read_text(encoding="utf-8")) @@ -207,11 +233,20 @@ def test_release_workflow_publishes_from_the_exact_app_tag_with_oidc(): ) assert publish["env"]["GH_TOKEN"] == "${{ steps.release-app.outputs.token }}" assert publish["env"]["RELEASE_TAG"] == "${{ github.ref_name }}" + assert publish["env"]["EXPECTED_AUTHOR"] == "openadapt-release[bot]" assert "gh release create" in publish["run"] assert "--verify-tag" in publish["run"] - assert "gh release edit" in publish["run"] - assert "gh release upload" in publish["run"] - assert "--clobber" in publish["run"] + assert "dist/*.whl dist/*.tar.gz" in publish["run"] + assert "gh release edit" not in publish["run"] + assert 'gh release upload "$RELEASE_TAG" "dist/$missing_asset"' in publish["run"] + assert "--allow-missing" in publish["run"] + assert "--missing-output /tmp/missing-release-assets.txt" in publish["run"] + assert "--clobber" not in publish["run"] + assert "scripts/verify_github_release.py" in publish["run"] + assert "--downloaded-dir /tmp/github-release-assets" in publish["run"] + assert 'release_status" = "404"' in publish["run"] + assert 'release_status" != "200"' in publish["run"] + assert 'tag_commit" != "$GITHUB_SHA' in publish["run"] assert "semantic-release" not in publish["run"] @@ -263,7 +298,9 @@ def test_release_workflow_publishes_the_attested_bytes_to_both_destinations(): assert github_publish["env"]["RELEASE_TAG"] == "${{ github.ref_name }}" checkout = next( - step for step in github_steps if step["name"] == "Checkout the exact release tag" + step + for step in github_steps + if step["name"] == "Checkout the exact release tag" ) assert checkout["with"] == {"ref": "${{ github.ref }}", "fetch-depth": 0} diff --git a/tests/test_verify_github_release.py b/tests/test_verify_github_release.py new file mode 100644 index 000000000..23d3ed585 --- /dev/null +++ b/tests/test_verify_github_release.py @@ -0,0 +1,141 @@ +import hashlib +import importlib.util +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[1] +SCRIPT = ROOT / "scripts" / "verify_github_release.py" +SPEC = importlib.util.spec_from_file_location("verify_github_release", SCRIPT) +assert SPEC and SPEC.loader +MODULE = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(MODULE) + + +def _sha256(path: Path) -> str: + return hashlib.sha256(path.read_bytes()).hexdigest() + + +@pytest.fixture +def release_candidate(tmp_path: Path) -> tuple[Path, Path, dict]: + expected = tmp_path / "expected" + downloaded = tmp_path / "downloaded" + expected.mkdir() + downloaded.mkdir() + + wheel = expected / "openadapt-1.16.0-py3-none-any.whl" + sdist = expected / "openadapt-1.16.0.tar.gz" + wheel.write_bytes(b"exact wheel bytes") + sdist.write_bytes(b"exact sdist bytes") + for path in (wheel, sdist): + (downloaded / path.name).write_bytes(path.read_bytes()) + + metadata = { + "tag_name": "v1.16.0", + "draft": False, + "prerelease": False, + "author": {"login": "openadapt-release[bot]"}, + "assets": [ + { + "name": path.name, + "size": path.stat().st_size, + "digest": f"sha256:{_sha256(path)}", + } + for path in (wheel, sdist) + ], + } + return expected, downloaded, metadata + + +def _verify(expected: Path, downloaded: Path, metadata: dict) -> None: + MODULE.verify_release( + metadata, + expected_tag="v1.16.0", + expected_author="openadapt-release[bot]", + expected_dir=expected, + downloaded_dir=downloaded, + ) + + +def test_exact_existing_release_is_idempotently_accepted(release_candidate): + expected, downloaded, metadata = release_candidate + + _verify(expected, downloaded, metadata) + + +@pytest.mark.parametrize( + ("field", "value", "message"), + [ + ("tag_name", "v1.15.0", "release tag mismatch"), + ("draft", True, "is a draft"), + ("prerelease", True, "is a prerelease"), + ("author", {"login": "abrichr"}, "release author mismatch"), + ], +) +def test_release_identity_or_state_mismatch_fails_closed( + release_candidate, field, value, message +): + expected, downloaded, metadata = release_candidate + metadata[field] = value + + with pytest.raises(ValueError, match=message): + _verify(expected, downloaded, metadata) + + +def test_missing_release_asset_fails_closed(release_candidate): + expected, downloaded, metadata = release_candidate + metadata["assets"].pop() + + with pytest.raises(ValueError, match="missing"): + _verify(expected, downloaded, metadata) + + +def test_exact_partial_release_can_add_only_the_missing_asset(release_candidate): + expected, downloaded, metadata = release_candidate + missing_asset = metadata["assets"].pop() + (downloaded / missing_asset["name"]).unlink() + + missing = MODULE.verify_release( + metadata, + expected_tag="v1.16.0", + expected_author="openadapt-release[bot]", + expected_dir=expected, + downloaded_dir=downloaded, + allow_missing=True, + ) + + assert missing == [missing_asset["name"]] + + metadata["assets"].append(missing_asset) + source = expected / missing_asset["name"] + (downloaded / missing_asset["name"]).write_bytes(source.read_bytes()) + _verify(expected, downloaded, metadata) + + +def test_unexpected_release_asset_fails_closed(release_candidate): + expected, downloaded, metadata = release_candidate + metadata["assets"].append( + {"name": "unexpected.txt", "size": 1, "digest": "sha256:" + "0" * 64} + ) + + with pytest.raises(ValueError, match="unexpected"): + _verify(expected, downloaded, metadata) + + +def test_release_asset_digest_mismatch_fails_closed(release_candidate): + expected, downloaded, metadata = release_candidate + metadata["assets"][0]["digest"] = "sha256:" + "0" * 64 + + with pytest.raises(ValueError, match="digest mismatch"): + _verify(expected, downloaded, metadata) + + +def test_downloaded_release_byte_mismatch_fails_closed(release_candidate): + expected, downloaded, metadata = release_candidate + name = metadata["assets"][0]["name"] + path = downloaded / name + original = (expected / name).read_bytes() + path.write_bytes(b"X" + original[1:]) + + with pytest.raises(ValueError, match="downloaded release asset bytes mismatch"): + _verify(expected, downloaded, metadata) From 6a40a309249589c047395e4ac05cebbb542e1717 Mon Sep 17 00:00:00 2001 From: abrichr Date: Wed, 26 Aug 2026 16:33:51 -0400 Subject: [PATCH 04/10] fix: verify launcher release app identity --- .github/workflows/release-and-publish.yml | 22 ++++++++++++++++++ tests/test_release_lock.py | 27 +++++++++++++++++++++++ 2 files changed, 49 insertions(+) diff --git a/.github/workflows/release-and-publish.yml b/.github/workflows/release-and-publish.yml index 09dc1326a..c9d39a9ce 100644 --- a/.github/workflows/release-and-publish.yml +++ b/.github/workflows/release-and-publish.yml @@ -47,6 +47,17 @@ jobs: repositories: ${{ github.event.repository.name }} permission-contents: write + - name: Require the exact release App identity for tag creation + env: + ACTUAL_APP_SLUG: ${{ steps.release-app.outputs.app-slug }} + EXPECTED_APP_SLUG: openadapt-release + run: | + set -euo pipefail + if [ "$ACTUAL_APP_SLUG" != "$EXPECTED_APP_SLUG" ]; then + echo "Tag creation requires the $EXPECTED_APP_SLUG App, not $ACTUAL_APP_SLUG." >&2 + exit 1 + fi + - name: Checkout the exact dispatched main commit uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -303,6 +314,17 @@ jobs: permission-contents: write permission-metadata: read + - name: Require the exact release App identity for GitHub publication + env: + ACTUAL_APP_SLUG: ${{ steps.release-app.outputs.app-slug }} + EXPECTED_APP_SLUG: openadapt-release + run: | + set -euo pipefail + if [ "$ACTUAL_APP_SLUG" != "$EXPECTED_APP_SLUG" ]; then + echo "GitHub publication requires the $EXPECTED_APP_SLUG App, not $ACTUAL_APP_SLUG." >&2 + exit 1 + fi + - name: Publish the GitHub Release and exact artifacts env: GH_TOKEN: ${{ steps.release-app.outputs.token }} diff --git a/tests/test_release_lock.py b/tests/test_release_lock.py index 87baeb8f6..cc95eab48 100644 --- a/tests/test_release_lock.py +++ b/tests/test_release_lock.py @@ -141,6 +141,21 @@ def test_release_workflow_app_creates_only_an_exact_reviewed_tag(): "repositories": "${{ github.event.repository.name }}", "permission-contents": "write", } + identity = next( + step + for step in create["steps"] + if step["name"] == "Require the exact release App identity for tag creation" + ) + assert identity["env"] == { + "ACTUAL_APP_SLUG": "${{ steps.release-app.outputs.app-slug }}", + "EXPECTED_APP_SLUG": "openadapt-release", + } + assert 'ACTUAL_APP_SLUG" != "$EXPECTED_APP_SLUG' in identity["run"] + assert create["steps"].index(identity) < next( + index + for index, step in enumerate(create["steps"]) + if step["name"] == "Create and push only the annotated release tag" + ) candidate = next(step for step in create["steps"] if step.get("id") == "candidate") assert 'GITHUB_REF" != "refs/heads/main' in candidate["run"] @@ -226,11 +241,23 @@ def test_release_workflow_publishes_from_the_exact_app_tag_with_oidc(): "permission-contents": "write", "permission-metadata": "read", } + identity = next( + step + for step in publish_steps + if step["name"] + == "Require the exact release App identity for GitHub publication" + ) + assert identity["env"] == { + "ACTUAL_APP_SLUG": "${{ steps.release-app.outputs.app-slug }}", + "EXPECTED_APP_SLUG": "openadapt-release", + } + assert 'ACTUAL_APP_SLUG" != "$EXPECTED_APP_SLUG' in identity["run"] publish = next( step for step in publish_steps if step["name"] == "Publish the GitHub Release and exact artifacts" ) + assert publish_steps.index(identity) < publish_steps.index(publish) assert publish["env"]["GH_TOKEN"] == "${{ steps.release-app.outputs.token }}" assert publish["env"]["RELEASE_TAG"] == "${{ github.ref_name }}" assert publish["env"]["EXPECTED_AUTHOR"] == "openadapt-release[bot]" From f2fba296db6a315f554242d6a63930cb430013e6 Mon Sep 17 00:00:00 2001 From: abrichr Date: Wed, 26 Aug 2026 16:51:49 -0400 Subject: [PATCH 05/10] Make release publication safely recoverable --- .github/workflows/release-and-publish.yml | 35 ++- scripts/verify_pypi_release.py | 328 ++++++++++++++++++++++ tests/test_release_lock.py | 39 +++ tests/test_verify_pypi_release.py | 227 +++++++++++++++ 4 files changed, 628 insertions(+), 1 deletion(-) create mode 100644 scripts/verify_pypi_release.py create mode 100644 tests/test_verify_pypi_release.py diff --git a/.github/workflows/release-and-publish.yml b/.github/workflows/release-and-publish.yml index c9d39a9ce..6a55dde96 100644 --- a/.github/workflows/release-and-publish.yml +++ b/.github/workflows/release-and-publish.yml @@ -266,17 +266,50 @@ jobs: id-token: write steps: + - name: Checkout the exact release tag + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.ref }} + persist-credentials: false + - name: Download attested release artifacts uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: release-dists-${{ github.ref_name }} path: dist/ + - name: Refuse conflicting immutable PyPI files + env: + RELEASE_TAG: ${{ github.ref_name }} + run: | + set -euo pipefail + version="${RELEASE_TAG#v}" + test "$RELEASE_TAG" = "v${version}" + python scripts/verify_pypi_release.py \ + --directory dist \ + --version "$version" \ + --allow-matching-subset \ + --wait-seconds 300 \ + --poll-seconds 10 + - name: Publish to PyPI with Trusted Publishing uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2 with: skip-existing: true + - name: Verify immutable PyPI publication bytes + env: + RELEASE_TAG: ${{ github.ref_name }} + run: | + set -euo pipefail + version="${RELEASE_TAG#v}" + test "$RELEASE_TAG" = "v${version}" + python scripts/verify_pypi_release.py \ + --directory dist \ + --version "$version" \ + --wait-seconds 300 \ + --poll-seconds 10 + publish-github: needs: [build-and-attest, publish-pypi] if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') @@ -602,7 +635,7 @@ jobs: create-tag=${CREATE_TAG_RESULT}, build-and-attest=${BUILD_RESULT}, publish-pypi=${PYPI_RESULT}, publish-github=${GITHUB_RESULT}, verify-publication=${VERIFY_RESULT}. - If the annotated tag exists, rerun this exact tag workflow. Do not create a recovery tag." + If the annotated tag exists, rerun only the failed jobs in this run with gh run rerun ${{ github.run_id }} --failed. Do not start a new full run or create a recovery tag." existing="$(gh issue list --repo "$GITHUB_REPOSITORY" --state open \ --search "in:title \"$TITLE\"" --json number,title \ --jq '[.[] | select(.title == env.TITLE)][0].number // empty')" diff --git a/scripts/verify_pypi_release.py b/scripts/verify_pypi_release.py new file mode 100644 index 000000000..4900b1e67 --- /dev/null +++ b/scripts/verify_pypi_release.py @@ -0,0 +1,328 @@ +#!/usr/bin/env python3 +"""Verify one immutable PyPI release against the exact local build.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import re +import time +import urllib.error +import urllib.request +from collections.abc import Callable +from dataclasses import dataclass +from pathlib import Path +from typing import Any +from urllib.parse import quote, urlsplit + +PYPI_PROJECT = "openadapt" +STABLE_VERSION = re.compile(r"^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$") +SHA256 = re.compile(r"^[0-9a-f]{64}$") +MAX_METADATA_BYTES = 4 * 1024 * 1024 +MAX_ARTIFACT_BYTES = 256 * 1024 * 1024 + + +class ReleaseVerificationError(RuntimeError): + """The public release does not equal the reviewed local build.""" + + +class PublicationPending(ReleaseVerificationError): + """The exact immutable PyPI publication is not visible yet.""" + + +class PublicationAbsent(PublicationPending): + """The exact version or one of its inventoried files is absent.""" + + +@dataclass(frozen=True) +class Artifact: + """One local immutable distribution file.""" + + name: str + package_type: str + body: bytes + sha256: str + + @property + def size(self) -> int: + return len(self.body) + + +Fetch = Callable[[str, int], bytes] + + +def _canonical_project(value: str) -> str: + return re.sub(r"[-_.]+", "-", value).lower() + + +def _fetch_url(url: str, limit: int) -> bytes: + request = urllib.request.Request( + url, + headers={"User-Agent": "openadapt-release-verifier/1"}, + ) + try: + with urllib.request.urlopen(request, timeout=30) as response: + body = response.read(limit + 1) + except urllib.error.HTTPError as exc: + if exc.code == 404: + raise PublicationAbsent(f"PyPI has not published {url}") from exc + raise ReleaseVerificationError( + f"PyPI request failed with HTTP {exc.code}: {url}" + ) from exc + except (urllib.error.URLError, TimeoutError, OSError) as exc: + raise PublicationPending(f"PyPI request is not ready: {url}: {exc}") from exc + if len(body) > limit: + raise ReleaseVerificationError(f"PyPI response exceeds the size limit: {url}") + return body + + +def _local_artifacts(directory: Path) -> dict[str, Artifact]: + if not directory.is_dir() or directory.is_symlink(): + raise ReleaseVerificationError( + f"distribution directory is missing or invalid: {directory}" + ) + + artifacts: dict[str, Artifact] = {} + for path in sorted(directory.iterdir()): + if not path.is_file() or path.is_symlink(): + raise ReleaseVerificationError( + f"unexpected local distribution: {path.name}" + ) + if path.name.endswith(".whl"): + package_type = "bdist_wheel" + elif path.name.endswith(".tar.gz"): + package_type = "sdist" + else: + raise ReleaseVerificationError( + f"unexpected local distribution: {path.name}" + ) + body = path.read_bytes() + if not body: + raise ReleaseVerificationError(f"local distribution is empty: {path.name}") + if len(body) > MAX_ARTIFACT_BYTES: + raise ReleaseVerificationError( + f"local distribution exceeds the size limit: {path.name}" + ) + artifacts[path.name] = Artifact( + name=path.name, + package_type=package_type, + body=body, + sha256=hashlib.sha256(body).hexdigest(), + ) + + if len(artifacts) != 2 or { + artifact.package_type for artifact in artifacts.values() + } != {"bdist_wheel", "sdist"}: + raise ReleaseVerificationError( + "local release must contain exactly one wheel and one sdist" + ) + return artifacts + + +def _metadata_object(body: bytes) -> dict[str, Any]: + try: + value = json.loads(body) + except (json.JSONDecodeError, UnicodeDecodeError) as exc: + raise ReleaseVerificationError("PyPI returned invalid JSON metadata") from exc + if not isinstance(value, dict): + raise ReleaseVerificationError("PyPI metadata is not an object") + return value + + +def _published_files(document: dict[str, Any]) -> dict[str, dict[str, Any]]: + urls = document.get("urls") + if not isinstance(urls, list): + raise ReleaseVerificationError("PyPI metadata has no release file inventory") + + published: dict[str, dict[str, Any]] = {} + for entry in urls: + if not isinstance(entry, dict): + raise ReleaseVerificationError("PyPI release file metadata is invalid") + filename = entry.get("filename") + if ( + not isinstance(filename, str) + or not filename + or Path(filename).name != filename + or filename in published + ): + raise ReleaseVerificationError( + f"PyPI release filename is invalid or duplicated: {filename!r}" + ) + published[filename] = entry + return published + + +def _artifact_url(entry: dict[str, Any], filename: str) -> str: + value = entry.get("url") + try: + parsed = urlsplit(value) if isinstance(value, str) else None + port = parsed.port if parsed is not None else None + except ValueError as exc: + raise ReleaseVerificationError( + f"PyPI artifact URL is invalid: {filename}" + ) from exc + if ( + parsed is None + or parsed.scheme != "https" + or parsed.netloc != "files.pythonhosted.org" + or parsed.username is not None + or parsed.password is not None + or port is not None + or parsed.query + or parsed.fragment + or Path(parsed.path).name != filename + ): + raise ReleaseVerificationError(f"PyPI artifact URL is invalid: {filename}") + return value + + +def verify_pypi_release( + directory: Path, + version: str, + *, + fetch: Fetch = _fetch_url, + allow_matching_subset: bool = False, +) -> None: + """Verify all public files; optionally allow an absent or matching subset.""" + + if STABLE_VERSION.fullmatch(version) is None: + raise ReleaseVerificationError( + f"version is not an exact stable X.Y.Z value: {version!r}" + ) + local = _local_artifacts(directory) + wheel = next(name for name in local if name.endswith(".whl")) + sdist = next(name for name in local if name.endswith(".tar.gz")) + normalized_release = f"openadapt-{version}" + if not wheel.startswith(f"{normalized_release}-"): + raise ReleaseVerificationError( + "local wheel does not identify the exact release" + ) + if sdist != f"{normalized_release}.tar.gz": + raise ReleaseVerificationError( + "local sdist does not identify the exact release" + ) + + metadata_url = ( + f"https://pypi.org/pypi/{PYPI_PROJECT}/{quote(version, safe='')}/json" + ) + try: + metadata_body = fetch(metadata_url, MAX_METADATA_BYTES) + except PublicationAbsent: + if allow_matching_subset: + return + raise + document = _metadata_object(metadata_body) + info = document.get("info") + if ( + not isinstance(info, dict) + or _canonical_project(str(info.get("name") or "")) != PYPI_PROJECT + or info.get("version") != version + ): + raise ReleaseVerificationError( + "PyPI metadata does not identify the requested package version" + ) + + published = _published_files(document) + expected_names = set(local) + published_names = set(published) + extras = sorted(published_names - expected_names) + if extras: + raise ReleaseVerificationError( + f"PyPI has unexpected immutable release files: {extras}" + ) + missing = sorted(expected_names - published_names) + if missing and not allow_matching_subset: + raise PublicationPending(f"PyPI release files are not visible yet: {missing}") + + for filename, artifact in local.items(): + if filename not in published: + continue + entry = published[filename] + digests = entry.get("digests") + remote_digest = digests.get("sha256") if isinstance(digests, dict) else None + remote_size = entry.get("size") + if ( + not isinstance(remote_digest, str) + or SHA256.fullmatch(remote_digest) is None + or isinstance(remote_size, bool) + or not isinstance(remote_size, int) + or remote_size <= 0 + or entry.get("packagetype") != artifact.package_type + or entry.get("yanked") is not False + ): + raise ReleaseVerificationError( + f"PyPI release file metadata is invalid: {filename}" + ) + if remote_digest != artifact.sha256 or remote_size != artifact.size: + raise ReleaseVerificationError( + f"PyPI release file metadata differs from the build: {filename}" + ) + remote_url = _artifact_url(entry, filename) + public_body = fetch(remote_url, min(MAX_ARTIFACT_BYTES, artifact.size + 1)) + if public_body != artifact.body: + raise ReleaseVerificationError( + f"PyPI release bytes differ from the build: {filename}" + ) + + +def _verify_with_wait( + directory: Path, + version: str, + *, + wait_seconds: int, + poll_seconds: int, + allow_matching_subset: bool, +) -> None: + deadline = time.monotonic() + wait_seconds + while True: + try: + verify_pypi_release( + directory, + version, + allow_matching_subset=allow_matching_subset, + ) + return + except PublicationPending: + if time.monotonic() >= deadline: + raise + time.sleep(poll_seconds) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--directory", type=Path, default=Path("dist")) + parser.add_argument("--version", required=True) + parser.add_argument("--wait-seconds", type=int, default=300) + parser.add_argument("--poll-seconds", type=int, default=10) + parser.add_argument( + "--allow-matching-subset", + action="store_true", + help="permit an absent release or byte-identical subset before publication", + ) + args = parser.parse_args() + if args.wait_seconds < 0 or args.poll_seconds <= 0: + parser.error( + "wait seconds must be nonnegative and poll seconds must be positive" + ) + try: + _verify_with_wait( + args.directory, + args.version, + wait_seconds=args.wait_seconds, + poll_seconds=args.poll_seconds, + allow_matching_subset=args.allow_matching_subset, + ) + except (OSError, ReleaseVerificationError) as exc: + parser.exit(1, f"{exc}\n") + scope = ( + "existing immutable PyPI bytes" + if args.allow_matching_subset + else "immutable PyPI bytes" + ) + print(f"Verified {scope} for {PYPI_PROJECT} {args.version}.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_release_lock.py b/tests/test_release_lock.py index cc95eab48..c397167c1 100644 --- a/tests/test_release_lock.py +++ b/tests/test_release_lock.py @@ -228,6 +228,22 @@ def test_release_workflow_publishes_from_the_exact_app_tag_with_oidc(): ) assert publish["uses"].startswith("pypa/gh-action-pypi-publish@") assert publish["with"] == {"skip-existing": True} + preflight = next( + step + for step in pypi["steps"] + if step["name"] == "Refuse conflicting immutable PyPI files" + ) + strict = next( + step + for step in pypi["steps"] + if step["name"] == "Verify immutable PyPI publication bytes" + ) + assert pypi["steps"].index(preflight) < pypi["steps"].index(publish) + assert pypi["steps"].index(publish) < pypi["steps"].index(strict) + assert "scripts/verify_pypi_release.py" in preflight["run"] + assert "--allow-matching-subset" in preflight["run"] + assert "scripts/verify_pypi_release.py" in strict["run"] + assert "--allow-matching-subset" not in strict["run"] assert jobs["publish-github"]["environment"] == "pypi" publish_steps = jobs["publish-github"]["steps"] @@ -324,6 +340,14 @@ def test_release_workflow_publishes_the_attested_bytes_to_both_destinations(): assert pypi_publish["with"] == {"skip-existing": True} assert github_publish["env"]["RELEASE_TAG"] == "${{ github.ref_name }}" + pypi_checkout = next( + step for step in pypi_steps if step["name"] == "Checkout the exact release tag" + ) + assert pypi_checkout["with"] == { + "ref": "${{ github.ref }}", + "persist-credentials": False, + } + checkout = next( step for step in github_steps @@ -341,3 +365,18 @@ def test_release_workflow_publishes_the_attested_bytes_to_both_destinations(): assert "gh release download" in verification_text assert "diff -u /tmp/source.sha256 /tmp/pypi.sha256" in verification_text assert "diff -u /tmp/source.sha256 /tmp/github.sha256" in verification_text + + +def test_release_failure_requires_failed_job_rerun_in_the_same_run(): + workflow_path = ROOT / ".github/workflows/release-and-publish.yml" + document = yaml.safe_load(workflow_path.read_text(encoding="utf-8")) + report = document["jobs"]["report-release-failure"] + run = next( + step["run"] + for step in report["steps"] + if step["name"] == "File or update the release failure issue" + ) + + assert "gh run rerun ${{ github.run_id }} --failed" in run + assert "Do not start a new full run" in run + assert "or create a recovery tag" in run diff --git a/tests/test_verify_pypi_release.py b/tests/test_verify_pypi_release.py new file mode 100644 index 000000000..92884352b --- /dev/null +++ b/tests/test_verify_pypi_release.py @@ -0,0 +1,227 @@ +"""Tests for exact immutable PyPI release verification.""" + +from __future__ import annotations + +import hashlib +import json +from pathlib import Path + +import pytest + +from scripts.verify_pypi_release import ( + MAX_METADATA_BYTES, + PYPI_PROJECT, + PublicationAbsent, + PublicationPending, + ReleaseVerificationError, + verify_pypi_release, +) + +VERSION = "9.8.7" + + +def _fixture(tmp_path: Path) -> tuple[Path, dict[str, bytes]]: + directory = tmp_path / "dist" + directory.mkdir() + local = { + f"openadapt-{VERSION}-py3-none-any.whl": b"reviewed wheel bytes", + f"openadapt-{VERSION}.tar.gz": b"reviewed sdist bytes", + } + urls = [] + bodies: dict[str, bytes] = {} + for filename, body in local.items(): + (directory / filename).write_bytes(body) + url = f"https://files.pythonhosted.org/packages/test/{filename}" + bodies[url] = body + urls.append( + { + "filename": filename, + "url": url, + "size": len(body), + "digests": {"sha256": hashlib.sha256(body).hexdigest()}, + "packagetype": ( + "bdist_wheel" if filename.endswith(".whl") else "sdist" + ), + "yanked": False, + } + ) + metadata_url = f"https://pypi.org/pypi/{PYPI_PROJECT}/{VERSION}/json" + bodies[metadata_url] = json.dumps( + {"info": {"name": PYPI_PROJECT, "version": VERSION}, "urls": urls} + ).encode() + return directory, bodies + + +def _fetch(bodies: dict[str, bytes]): + def fetch(url: str, limit: int) -> bytes: + body = bodies[url] + assert len(body) <= limit + return body + + return fetch + + +def _metadata(bodies: dict[str, bytes]) -> tuple[str, dict]: + metadata_url = f"https://pypi.org/pypi/{PYPI_PROJECT}/{VERSION}/json" + return metadata_url, json.loads(bodies[metadata_url]) + + +def test_exact_public_files_match_names_metadata_hashes_and_bytes( + tmp_path: Path, +) -> None: + directory, bodies = _fixture(tmp_path) + calls: list[tuple[str, int]] = [] + + def fetch(url: str, limit: int) -> bytes: + calls.append((url, limit)) + return bodies[url] + + verify_pypi_release(directory, VERSION, fetch=fetch) + + assert calls[0] == ( + f"https://pypi.org/pypi/{PYPI_PROJECT}/{VERSION}/json", + MAX_METADATA_BYTES, + ) + assert {url for url, _ in calls[1:]} == { + url for url in bodies if url.startswith("https://files.pythonhosted.org/") + } + + +def test_missing_public_file_is_pending_for_bounded_recovery(tmp_path: Path) -> None: + directory, bodies = _fixture(tmp_path) + metadata_url, metadata = _metadata(bodies) + metadata["urls"].pop() + bodies[metadata_url] = json.dumps(metadata).encode() + + with pytest.raises(PublicationPending, match="not visible yet"): + verify_pypi_release(directory, VERSION, fetch=_fetch(bodies)) + + +def test_matching_public_subset_is_accepted_before_publication(tmp_path: Path) -> None: + directory, bodies = _fixture(tmp_path) + metadata_url, metadata = _metadata(bodies) + missing = metadata["urls"].pop() + bodies.pop(missing["url"]) + bodies[metadata_url] = json.dumps(metadata).encode() + + verify_pypi_release( + directory, + VERSION, + fetch=_fetch(bodies), + allow_matching_subset=True, + ) + + +def test_absent_release_is_accepted_only_before_publication(tmp_path: Path) -> None: + directory, _ = _fixture(tmp_path) + + def absent(_url: str, _limit: int) -> bytes: + raise PublicationAbsent("not published") + + verify_pypi_release( + directory, + VERSION, + fetch=absent, + allow_matching_subset=True, + ) + with pytest.raises(PublicationAbsent): + verify_pypi_release(directory, VERSION, fetch=absent) + + +def test_empty_release_inventory_is_accepted_before_publication(tmp_path: Path) -> None: + directory, bodies = _fixture(tmp_path) + metadata_url, metadata = _metadata(bodies) + metadata["urls"] = [] + bodies[metadata_url] = json.dumps(metadata).encode() + + verify_pypi_release( + directory, + VERSION, + fetch=_fetch(bodies), + allow_matching_subset=True, + ) + + +def test_extra_immutable_public_file_is_refused(tmp_path: Path) -> None: + directory, bodies = _fixture(tmp_path) + metadata_url, metadata = _metadata(bodies) + metadata["urls"].append( + dict(metadata["urls"][0], filename="openadapt-9.8.7-extra.whl") + ) + bodies[metadata_url] = json.dumps(metadata).encode() + + for allow_matching_subset in (False, True): + with pytest.raises(ReleaseVerificationError, match="unexpected immutable"): + verify_pypi_release( + directory, + VERSION, + fetch=_fetch(bodies), + allow_matching_subset=allow_matching_subset, + ) + + +@pytest.mark.parametrize("field", ["digest", "size", "type", "yanked", "bytes"]) +def test_changed_existing_subset_is_refused_before_publication( + tmp_path: Path, field: str +) -> None: + directory, bodies = _fixture(tmp_path) + metadata_url, metadata = _metadata(bodies) + missing = metadata["urls"].pop() + bodies.pop(missing["url"]) + entry = metadata["urls"][0] + if field == "digest": + entry["digests"]["sha256"] = "0" * 64 + elif field == "size": + entry["size"] += 1 + elif field == "type": + entry["packagetype"] = "sdist" + elif field == "yanked": + entry["yanked"] = True + else: + bodies[entry["url"]] = b"X" * entry["size"] + bodies[metadata_url] = json.dumps(metadata).encode() + + with pytest.raises(ReleaseVerificationError): + verify_pypi_release( + directory, + VERSION, + fetch=_fetch(bodies), + allow_matching_subset=True, + ) + + +@pytest.mark.parametrize("mutation", ["project", "version", "host", "duplicate"]) +def test_wrong_release_identity_or_file_source_is_refused( + tmp_path: Path, mutation: str +) -> None: + directory, bodies = _fixture(tmp_path) + metadata_url, metadata = _metadata(bodies) + if mutation == "project": + metadata["info"]["name"] = "other-project" + elif mutation == "version": + metadata["info"]["version"] = "9.8.8" + elif mutation == "host": + metadata["urls"][0]["url"] = "https://example.com/package.whl" + else: + metadata["urls"].append(dict(metadata["urls"][0])) + bodies[metadata_url] = json.dumps(metadata).encode() + + with pytest.raises(ReleaseVerificationError): + verify_pypi_release(directory, VERSION, fetch=_fetch(bodies)) + + +def test_local_release_requires_only_one_wheel_and_one_sdist(tmp_path: Path) -> None: + directory, bodies = _fixture(tmp_path) + (directory / "notes.txt").write_text("unexpected", encoding="utf-8") + + with pytest.raises(ReleaseVerificationError, match="unexpected local"): + verify_pypi_release(directory, VERSION, fetch=_fetch(bodies)) + + +def test_local_distribution_names_must_identify_exact_version(tmp_path: Path) -> None: + directory, bodies = _fixture(tmp_path) + wheel = next(directory.glob("*.whl")) + wheel.rename(directory / wheel.name.replace(VERSION, "9.8.8")) + + with pytest.raises(ReleaseVerificationError, match="wheel does not identify"): + verify_pypi_release(directory, VERSION, fetch=_fetch(bodies)) From ba00ced31d7a795a3113801378758fe9235a19b3 Mon Sep 17 00:00:00 2001 From: abrichr Date: Wed, 26 Aug 2026 17:03:52 -0400 Subject: [PATCH 06/10] Make PyPI verifier tests checkout-independent --- tests/test_verify_pypi_release.py | 24 ++++++++++++++++-------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/tests/test_verify_pypi_release.py b/tests/test_verify_pypi_release.py index 92884352b..dc8525a75 100644 --- a/tests/test_verify_pypi_release.py +++ b/tests/test_verify_pypi_release.py @@ -3,19 +3,27 @@ from __future__ import annotations import hashlib +import importlib.util import json +import sys from pathlib import Path import pytest -from scripts.verify_pypi_release import ( - MAX_METADATA_BYTES, - PYPI_PROJECT, - PublicationAbsent, - PublicationPending, - ReleaseVerificationError, - verify_pypi_release, -) +ROOT = Path(__file__).resolve().parents[1] +SCRIPT = ROOT / "scripts" / "verify_pypi_release.py" +SPEC = importlib.util.spec_from_file_location("verify_pypi_release", SCRIPT) +assert SPEC and SPEC.loader +MODULE = importlib.util.module_from_spec(SPEC) +sys.modules[SPEC.name] = MODULE +SPEC.loader.exec_module(MODULE) + +MAX_METADATA_BYTES = MODULE.MAX_METADATA_BYTES +PYPI_PROJECT = MODULE.PYPI_PROJECT +PublicationAbsent = MODULE.PublicationAbsent +PublicationPending = MODULE.PublicationPending +ReleaseVerificationError = MODULE.ReleaseVerificationError +verify_pypi_release = MODULE.verify_pypi_release VERSION = "9.8.7" From feaa44e045cc3555f559cc9f9e916631f0748d89 Mon Sep 17 00:00:00 2001 From: abrichr Date: Thu, 27 Aug 2026 13:05:00 -0400 Subject: [PATCH 07/10] Harden immutable launcher publication --- .github/workflows/release-and-publish.yml | 148 +++++++++++++++++++--- scripts/release_platform_versions.py | 107 ++++++++++++++++ scripts/verify_github_release.py | 54 ++++++-- scripts/verify_release_artifacts.py | 23 +++- tests/test_release_artifacts.py | 14 ++ tests/test_release_lock.py | 33 ++++- tests/test_release_platform_versions.py | 87 +++++++++++++ tests/test_verify_github_release.py | 43 ++++++- 8 files changed, 473 insertions(+), 36 deletions(-) create mode 100644 scripts/release_platform_versions.py create mode 100644 tests/test_release_platform_versions.py diff --git a/.github/workflows/release-and-publish.yml b/.github/workflows/release-and-publish.yml index 6a55dde96..c11a1f826 100644 --- a/.github/workflows/release-and-publish.yml +++ b/.github/workflows/release-and-publish.yml @@ -23,9 +23,9 @@ on: permissions: contents: read -# A tag run is the recovery unit. Do not cancel it when another release starts. +# Serialize release mutations. A newer candidate must not race an older recovery. concurrency: - group: release-${{ github.event_name }}-${{ github.ref }} + group: release-publication cancel-in-progress: false jobs: @@ -254,7 +254,7 @@ jobs: dist/*.whl dist/*.tar.gz if-no-files-found: error - retention-days: 1 + retention-days: 30 publish-pypi: needs: build-and-attest @@ -314,7 +314,7 @@ jobs: needs: [build-and-attest, publish-pypi] if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') runs-on: ubuntu-latest - environment: pypi + environment: release-identity permissions: contents: read @@ -344,6 +344,7 @@ jobs: private-key: ${{ secrets.OPENADAPT_RELEASE_APP_PRIVATE_KEY }} owner: ${{ github.repository_owner }} repositories: ${{ github.event.repository.name }} + permission-administration: read permission-contents: write permission-metadata: read @@ -358,9 +359,26 @@ jobs: exit 1 fi + - name: Install the pinned GitHub release verification CLI + id: release-cli + run: | + set -euo pipefail + archive=/tmp/gh_2.98.0_linux_amd64.tar.gz + curl --fail --location --silent --show-error \ + https://github.com/cli/cli/releases/download/v2.98.0/gh_2.98.0_linux_amd64.tar.gz \ + --output "$archive" + echo "3b8ac6b30336802fc1a858d7c084e11cdf24ac1a761ca90b68022d7d729208de $archive" \ + | sha256sum --check --strict + tar --extract --gzip --file "$archive" --directory /tmp + cli=/tmp/gh_2.98.0_linux_amd64/bin/gh + test -x "$cli" + "$cli" version + echo "path=$cli" >> "$GITHUB_OUTPUT" + - name: Publish the GitHub Release and exact artifacts env: GH_TOKEN: ${{ steps.release-app.outputs.token }} + GH_CLI: ${{ steps.release-cli.outputs.path }} RELEASE_TAG: ${{ github.ref_name }} EXPECTED_AUTHOR: openadapt-release[bot] run: | @@ -390,36 +408,68 @@ jobs: exit 1 fi + immutable_url="$GITHUB_API_URL/repos/$GITHUB_REPOSITORY/immutable-releases" + immutable_status="$(curl --silent --show-error \ + --output /tmp/immutable-releases.json \ + --write-out '%{http_code}' \ + --header "Accept: application/vnd.github+json" \ + --header "Authorization: Bearer $GH_TOKEN" \ + --header "X-GitHub-Api-Version: 2026-03-10" \ + "$immutable_url")" + if [ "$immutable_status" != "200" ]; then + echo "GitHub immutable releases are unavailable (HTTP $immutable_status)." >&2 + exit 1 + fi + python - <<'PY' + import json + from pathlib import Path + + document = json.loads( + Path("/tmp/immutable-releases.json").read_text(encoding="utf-8") + ) + if document.get("enabled") is not True: + raise SystemExit("GitHub immutable releases are not enabled.") + PY + release_url="$GITHUB_API_URL/repos/$GITHUB_REPOSITORY/releases/tags/$RELEASE_TAG" release_status="$(curl --silent --show-error \ --output /tmp/github-release.json \ --write-out '%{http_code}' \ --header "Accept: application/vnd.github+json" \ --header "Authorization: Bearer $GH_TOKEN" \ - --header "X-GitHub-Api-Version: 2022-11-28" \ + --header "X-GitHub-Api-Version: 2026-03-10" \ "$release_url")" if [ "$release_status" = "404" ]; then - gh release create "$RELEASE_TAG" \ + "$GH_CLI" release create "$RELEASE_TAG" \ dist/*.whl dist/*.tar.gz \ --repo "$GITHUB_REPOSITORY" \ + --draft \ --verify-tag \ --title "$RELEASE_TAG" \ - --notes-file /tmp/release-notes.md \ - --latest + --notes-file /tmp/release-notes.md release_status="$(curl --silent --show-error \ --output /tmp/github-release.json \ --write-out '%{http_code}' \ --header "Accept: application/vnd.github+json" \ --header "Authorization: Bearer $GH_TOKEN" \ - --header "X-GitHub-Api-Version: 2022-11-28" \ + --header "X-GitHub-Api-Version: 2026-03-10" \ "$release_url")" - elif [ "$release_status" = "200" ]; then + fi + if [ "$release_status" != "200" ]; then + echo "GitHub returned HTTP $release_status for the exact release." >&2 + exit 1 + fi + + release_state="$(python -c \ + 'import json; print("draft" if json.load(open("/tmp/github-release.json"))["draft"] else "published")')" + if [ "$release_state" = "draft" ]; then python scripts/verify_github_release.py \ --metadata /tmp/github-release.json \ --tag "$RELEASE_TAG" \ --author "$EXPECTED_AUTHOR" \ --expected-dir dist \ + --state draft \ --allow-missing \ --missing-output /tmp/missing-release-assets.txt @@ -427,7 +477,7 @@ jobs: asset_count="$(python -c \ 'import json; print(len(json.load(open("/tmp/github-release.json"))["assets"]))')" if [ "$asset_count" -gt 0 ]; then - gh release download "$RELEASE_TAG" \ + "$GH_CLI" release download "$RELEASE_TAG" \ --repo "$GITHUB_REPOSITORY" \ --dir /tmp/existing-github-release-assets fi @@ -437,11 +487,12 @@ jobs: --author "$EXPECTED_AUTHOR" \ --expected-dir dist \ --downloaded-dir /tmp/existing-github-release-assets \ + --state draft \ --allow-missing \ --missing-output /tmp/missing-release-assets.txt while IFS= read -r missing_asset; do - gh release upload "$RELEASE_TAG" "dist/$missing_asset" \ + "$GH_CLI" release upload "$RELEASE_TAG" "dist/$missing_asset" \ --repo "$GITHUB_REPOSITORY" done < /tmp/missing-release-assets.txt @@ -450,12 +501,40 @@ jobs: --write-out '%{http_code}' \ --header "Accept: application/vnd.github+json" \ --header "Authorization: Bearer $GH_TOKEN" \ - --header "X-GitHub-Api-Version: 2022-11-28" \ + --header "X-GitHub-Api-Version: 2026-03-10" \ "$release_url")" - fi - if [ "$release_status" != "200" ]; then - echo "GitHub returned HTTP $release_status for the exact release." >&2 - exit 1 + if [ "$release_status" != "200" ]; then + echo "GitHub returned HTTP $release_status for the completed draft." >&2 + exit 1 + fi + + mkdir /tmp/completed-draft-release-assets + "$GH_CLI" release download "$RELEASE_TAG" \ + --repo "$GITHUB_REPOSITORY" \ + --dir /tmp/completed-draft-release-assets + python scripts/verify_github_release.py \ + --metadata /tmp/github-release.json \ + --tag "$RELEASE_TAG" \ + --author "$EXPECTED_AUTHOR" \ + --expected-dir dist \ + --downloaded-dir /tmp/completed-draft-release-assets \ + --state draft + + "$GH_CLI" release edit "$RELEASE_TAG" \ + --repo "$GITHUB_REPOSITORY" \ + --draft=false + + release_status="$(curl --silent --show-error \ + --output /tmp/github-release.json \ + --write-out '%{http_code}' \ + --header "Accept: application/vnd.github+json" \ + --header "Authorization: Bearer $GH_TOKEN" \ + --header "X-GitHub-Api-Version: 2026-03-10" \ + "$release_url")" + if [ "$release_status" != "200" ]; then + echo "GitHub returned HTTP $release_status after publication." >&2 + exit 1 + fi fi python scripts/verify_github_release.py \ @@ -465,7 +544,7 @@ jobs: --expected-dir dist mkdir /tmp/github-release-assets - gh release download "$RELEASE_TAG" \ + "$GH_CLI" release download "$RELEASE_TAG" \ --repo "$GITHUB_REPOSITORY" \ --dir /tmp/github-release-assets python scripts/verify_github_release.py \ @@ -475,6 +554,23 @@ jobs: --expected-dir dist \ --downloaded-dir /tmp/github-release-assets + for attempt in $(seq 1 12); do + if "$GH_CLI" release verify "$RELEASE_TAG" \ + --repo "$GITHUB_REPOSITORY" \ + --format json > /tmp/github-release-attestation.json; then + break + fi + if [ "$attempt" -eq 12 ]; then + echo "GitHub did not produce a valid release attestation in time." >&2 + exit 1 + fi + sleep 10 + done + for artifact in dist/*.whl dist/*.tar.gz; do + "$GH_CLI" release verify-asset "$RELEASE_TAG" "$artifact" \ + --repo "$GITHUB_REPOSITORY" + done + verify-publication: needs: [build-and-attest, publish-pypi, publish-github] if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') @@ -507,10 +603,23 @@ jobs: RELEASE_TAG: ${{ github.ref_name }} run: | set -euo pipefail + version="${RELEASE_TAG#v}" + test "$RELEASE_TAG" = "v${version}" + python scripts/release_platform_versions.py \ + --manifest platform-manifest.json \ + --launcher-version "$version" \ + > /tmp/release-component-versions.txt + + component_args=() + while IFS= read -r component_version; do + component_args+=(--component-version "$component_version") + done < /tmp/release-component-versions.txt + for attempt in $(seq 1 12); do if python scripts/generate_platform_manifest.py \ --output /tmp/published-platform-manifest.json \ - --report-output /tmp/published-platform-report.md; then + --report-output /tmp/published-platform-report.md \ + "${component_args[@]}"; then break fi if [ "$attempt" -eq 12 ]; then @@ -522,6 +631,7 @@ jobs: python scripts/validate_platform_manifest.py \ --manifest /tmp/published-platform-manifest.json \ --report /tmp/published-platform-report.md \ + --require-compatible \ --require-network python - <<'PY' diff --git a/scripts/release_platform_versions.py b/scripts/release_platform_versions.py new file mode 100644 index 000000000..91321be5b --- /dev/null +++ b/scripts/release_platform_versions.py @@ -0,0 +1,107 @@ +#!/usr/bin/env python3 +"""Select the exact reviewed package versions for one launcher release.""" + +from __future__ import annotations + +import argparse +import json +import re +from pathlib import Path +from typing import Any + +MANIFEST_KIND = "openadapt-platform-release-manifest" +COMPONENT_PACKAGES = { + "launcher": "openadapt", + "flow": "openadapt-flow", + "capture": "openadapt-capture", + "privacy": "openadapt-privacy", + "types": "openadapt-types", + "desktop": "openadapt-desktop", + "agent": "openadapt-agent", +} +STABLE_VERSION = re.compile(r"^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$") + + +def _object(value: Any, context: str) -> dict[str, Any]: + if not isinstance(value, dict): + raise ValueError(f"{context} must be an object") + return value + + +def release_component_versions( + document: dict[str, Any], launcher_version: str +) -> dict[str, str]: + """Return the closed seven-package selection for the release transaction.""" + + if document.get("manifest_kind") != MANIFEST_KIND: + raise ValueError("platform manifest kind is invalid") + schema_version = document.get("schema_version") + if not isinstance(schema_version, str) or not schema_version.startswith("2."): + raise ValueError("platform manifest schema is not v2") + if STABLE_VERSION.fullmatch(launcher_version) is None: + raise ValueError("launcher version must be an exact stable X.Y.Z value") + + components = _object(document.get("components"), "platform components") + if set(components) != set(COMPONENT_PACKAGES): + raise ValueError("platform manifest must contain the exact public package set") + + selection = _object(document.get("release_selection"), "release selection") + if set(selection) != {"mode", "component_versions"}: + raise ValueError( + "release selection must contain only mode and component_versions" + ) + if selection.get("mode") != "explicit-published": + raise ValueError("release selection must use explicit-published mode") + selected_versions = _object( + selection.get("component_versions"), "selected component versions" + ) + if set(selected_versions) != set(COMPONENT_PACKAGES): + raise ValueError("release selection must pin the exact public package set") + + versions: dict[str, str] = {} + for role, package in COMPONENT_PACKAGES.items(): + component = _object(components.get(role), f"{role} component") + if component.get("package") != package: + raise ValueError(f"{role} component package is invalid") + version = component.get("version") + if not isinstance(version, str) or STABLE_VERSION.fullmatch(version) is None: + raise ValueError(f"{role} version must be an exact stable X.Y.Z value") + if selected_versions.get(role) != version: + raise ValueError(f"{role} release selection does not match its component") + versions[role] = version + if versions["launcher"] != launcher_version: + raise ValueError("launcher release selection does not match the candidate") + return versions + + +def load_release_component_versions( + manifest_path: Path, launcher_version: str +) -> dict[str, str]: + if manifest_path.is_symlink() or not manifest_path.is_file(): + raise ValueError("platform manifest path is missing or invalid") + try: + document = json.loads(manifest_path.read_text(encoding="utf-8")) + except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc: + raise ValueError(f"cannot read the platform manifest: {exc}") from exc + return release_component_versions( + _object(document, "platform manifest"), launcher_version + ) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--manifest", type=Path, required=True) + parser.add_argument("--launcher-version", required=True) + args = parser.parse_args() + try: + versions = load_release_component_versions(args.manifest, args.launcher_version) + except ValueError as exc: + parser.exit(1, f"{exc}\n") + + for role in COMPONENT_PACKAGES: + print(f"{role}={versions[role]}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/verify_github_release.py b/scripts/verify_github_release.py index db71bd96f..ec506fcb6 100644 --- a/scripts/verify_github_release.py +++ b/scripts/verify_github_release.py @@ -19,26 +19,37 @@ def _sha256(path: Path) -> str: def _artifact_files(directory: Path, *, allow_empty: bool = False) -> dict[str, Path]: - if not directory.is_dir(): + if directory.is_symlink() or not directory.is_dir(): raise ValueError(f"artifact directory does not exist: {directory}") + entries = list(directory.iterdir()) + invalid = sorted( + path.name for path in entries if path.is_symlink() or not path.is_file() + ) + if invalid: + raise ValueError( + f"artifact directory contains invalid entries: {', '.join(invalid)}" + ) files = { path.name: path - for path in directory.iterdir() - if path.is_file() and (path.suffix == ".whl" or path.name.endswith(".tar.gz")) + for path in entries + if path.suffix == ".whl" or path.name.endswith(".tar.gz") } if not files and not allow_empty: raise ValueError(f"artifact directory has no wheel or sdist: {directory}") - unexpected = sorted( - path.name - for path in directory.iterdir() - if path.is_file() and path.name not in files - ) + unexpected = sorted(path.name for path in entries if path.name not in files) if unexpected: raise ValueError( f"artifact directory contains unexpected files: {', '.join(unexpected)}" ) + package_types = {"wheel" if name.endswith(".whl") else "sdist" for name in files} + if ( + not allow_empty + and files + and (len(files) != 2 or package_types != {"wheel", "sdist"}) + ): + raise ValueError("artifact directory must contain one wheel and one sdist") return files @@ -50,16 +61,30 @@ def verify_release( expected_dir: Path, downloaded_dir: Path | None = None, allow_missing: bool = False, + expected_state: str = "published", ) -> list[str]: if document.get("tag_name") != expected_tag: raise ValueError( f"release tag mismatch: expected {expected_tag!r}, " f"found {document.get('tag_name')!r}" ) - if document.get("draft") is not False: - raise ValueError("the GitHub Release is a draft") + if expected_state not in {"draft", "published"}: + raise ValueError(f"unknown GitHub Release state: {expected_state}") + expected_draft = expected_state == "draft" + if document.get("draft") is not expected_draft: + raise ValueError(f"the GitHub Release is not {expected_state}") if document.get("prerelease") is not False: raise ValueError("the GitHub Release is a prerelease") + if expected_draft: + if document.get("immutable") is not False: + raise ValueError("the draft GitHub Release is immutable") + if document.get("published_at") is not None: + raise ValueError("the draft GitHub Release has a publication time") + else: + if document.get("immutable") is not True: + raise ValueError("the published GitHub Release is mutable") + if not isinstance(document.get("published_at"), str): + raise ValueError("the published GitHub Release has no publication time") author = document.get("author") actual_author = author.get("login") if isinstance(author, dict) else None @@ -102,6 +127,13 @@ def verify_release( digest = _sha256(path) expected_digests[name] = digest asset = assets[name] + uploader = asset.get("uploader") + actual_uploader = uploader.get("login") if isinstance(uploader, dict) else None + if actual_uploader != expected_author: + raise ValueError( + f"release asset uploader mismatch: expected {expected_author!r}, " + f"found {actual_uploader!r}: {name}" + ) if asset.get("size") != path.stat().st_size: raise ValueError(f"release asset size mismatch: {name}") if asset.get("digest") != f"sha256:{digest}": @@ -131,6 +163,7 @@ def main() -> int: parser.add_argument("--downloaded-dir", type=Path) parser.add_argument("--allow-missing", action="store_true") parser.add_argument("--missing-output", type=Path) + parser.add_argument("--state", choices=("draft", "published"), default="published") args = parser.parse_args() try: @@ -144,6 +177,7 @@ def main() -> int: expected_dir=args.expected_dir, downloaded_dir=args.downloaded_dir, allow_missing=args.allow_missing, + expected_state=args.state, ) if args.missing_output is not None: args.missing_output.write_text( diff --git a/scripts/verify_release_artifacts.py b/scripts/verify_release_artifacts.py index 476942bf5..a45ac211b 100644 --- a/scripts/verify_release_artifacts.py +++ b/scripts/verify_release_artifacts.py @@ -90,13 +90,22 @@ def verify_release_artifacts( root: Path = ROOT, ) -> tuple[Path, Path]: """Return the verified ``(wheel, sdist)`` paths or raise ``ValueError``.""" + if dist_dir.is_symlink() or not dist_dir.is_dir(): + raise ValueError( + f"release artifact directory is missing or invalid: {dist_dir}" + ) package_name, project_version, project_requires_python = _project_identity(root) wheel_name = re.sub(r"[-_.]+", "_", package_name) sdist_name = re.sub(r"[-_.]+", "-", package_name) wheels = sorted(dist_dir.glob(f"{wheel_name}-{project_version}-*.whl")) sdist = dist_dir / f"{sdist_name}-{project_version}.tar.gz" - if len(wheels) != 1 or not sdist.is_file(): + if ( + len(wheels) != 1 + or wheels[0].is_symlink() + or not sdist.is_file() + or sdist.is_symlink() + ): raise ValueError( f"expected one {wheel_name}-{project_version}-*.whl and {sdist.name}" ) @@ -104,11 +113,21 @@ def verify_release_artifacts( expected = {wheels[0], sdist} marker = dist_dir / ".gitignore" allowed = set(expected) + if marker.is_symlink(): + raise ValueError("dist/.gitignore must not be a symlink") if marker.is_file(): if marker.read_bytes() not in {b"", b"\n", b"*"}: raise ValueError("dist/.gitignore contains unexpected data") allowed.add(marker) - actual = {path for path in dist_dir.iterdir() if path.is_file()} + entries = set(dist_dir.iterdir()) + invalid = sorted( + path.name for path in entries if path.is_symlink() or not path.is_file() + ) + if invalid: + raise ValueError( + f"release artifact directory contains invalid entries: {', '.join(invalid)}" + ) + actual = entries if actual != allowed: unexpected = ", ".join(sorted(path.name for path in actual - allowed)) missing = ", ".join(sorted(path.name for path in allowed - actual)) diff --git a/tests/test_release_artifacts.py b/tests/test_release_artifacts.py index 07139717b..0f780296e 100644 --- a/tests/test_release_artifacts.py +++ b/tests/test_release_artifacts.py @@ -172,6 +172,20 @@ def test_release_artifacts_reject_unexpected_files(tmp_path: Path): verify_release_artifacts(dist, root=tmp_path) +def test_release_artifacts_reject_directory_and_symlink_entries(tmp_path: Path): + dist, wheel = _release_tree(tmp_path) + (dist / "nested").mkdir() + + with pytest.raises(ValueError, match="invalid entries: nested"): + verify_release_artifacts(dist, root=tmp_path) + + (dist / "nested").rmdir() + wheel.unlink() + wheel.symlink_to(tmp_path / "pyproject.toml") + with pytest.raises(ValueError, match="expected one"): + verify_release_artifacts(dist, root=tmp_path) + + def test_release_artifacts_reject_metadata_version_drift(tmp_path: Path): dist, _ = _release_tree(tmp_path, artifact_version="1.9.9") diff --git a/tests/test_release_lock.py b/tests/test_release_lock.py index c397167c1..1cdd67335 100644 --- a/tests/test_release_lock.py +++ b/tests/test_release_lock.py @@ -96,6 +96,10 @@ def test_release_workflow_pins_actions_and_separates_permissions(): assert 'requires = ["hatchling==1.32.0"]' in metadata assert document["permissions"] == {"contents": "read"} + assert document["concurrency"] == { + "group": "release-publication", + "cancel-in-progress": False, + } jobs = document["jobs"] assert jobs["create-release-tag"]["permissions"] == {"contents": "read"} assert jobs["build-and-attest"]["permissions"] == { @@ -245,7 +249,7 @@ def test_release_workflow_publishes_from_the_exact_app_tag_with_oidc(): assert "scripts/verify_pypi_release.py" in strict["run"] assert "--allow-matching-subset" not in strict["run"] - assert jobs["publish-github"]["environment"] == "pypi" + assert jobs["publish-github"]["environment"] == "release-identity" publish_steps = jobs["publish-github"]["steps"] app = next(step for step in publish_steps if step.get("id") == "release-app") assert app["uses"].startswith("actions/create-github-app-token@") @@ -254,6 +258,7 @@ def test_release_workflow_publishes_from_the_exact_app_tag_with_oidc(): "private-key": "${{ secrets.OPENADAPT_RELEASE_APP_PRIVATE_KEY }}", "owner": "${{ github.repository_owner }}", "repositories": "${{ github.event.repository.name }}", + "permission-administration": "read", "permission-contents": "write", "permission-metadata": "read", } @@ -277,11 +282,17 @@ def test_release_workflow_publishes_from_the_exact_app_tag_with_oidc(): assert publish["env"]["GH_TOKEN"] == "${{ steps.release-app.outputs.token }}" assert publish["env"]["RELEASE_TAG"] == "${{ github.ref_name }}" assert publish["env"]["EXPECTED_AUTHOR"] == "openadapt-release[bot]" - assert "gh release create" in publish["run"] + assert '"$GH_CLI" release create' in publish["run"] assert "--verify-tag" in publish["run"] + assert "--draft" in publish["run"] assert "dist/*.whl dist/*.tar.gz" in publish["run"] - assert "gh release edit" not in publish["run"] - assert 'gh release upload "$RELEASE_TAG" "dist/$missing_asset"' in publish["run"] + assert '"$GH_CLI" release edit "$RELEASE_TAG"' in publish["run"] + assert "--draft=false" in publish["run"] + assert "--latest" not in publish["run"] + assert ( + '"$GH_CLI" release upload "$RELEASE_TAG" "dist/$missing_asset"' + in publish["run"] + ) assert "--allow-missing" in publish["run"] assert "--missing-output /tmp/missing-release-assets.txt" in publish["run"] assert "--clobber" not in publish["run"] @@ -290,8 +301,18 @@ def test_release_workflow_publishes_from_the_exact_app_tag_with_oidc(): assert 'release_status" = "404"' in publish["run"] assert 'release_status" != "200"' in publish["run"] assert 'tag_commit" != "$GITHUB_SHA' in publish["run"] + assert "immutable-releases" in publish["run"] + assert 'document.get("enabled") is not True' in publish["run"] + assert '"$GH_CLI" release verify "$RELEASE_TAG"' in publish["run"] + assert '"$GH_CLI" release verify-asset "$RELEASE_TAG" "$artifact"' in publish["run"] assert "semantic-release" not in publish["run"] + cli = next(step for step in publish_steps if step.get("id") == "release-cli") + assert "gh_2.98.0_linux_amd64.tar.gz" in cli["run"] + assert ( + "3b8ac6b30336802fc1a858d7c084e11cdf24ac1a761ca90b68022d7d729208de" in cli["run"] + ) + def test_release_workflow_publishes_the_attested_bytes_to_both_destinations(): workflow_path = ROOT / ".github/workflows/release-and-publish.yml" @@ -314,6 +335,7 @@ def test_release_workflow_publishes_the_attested_bytes_to_both_destinations(): "dist/*.tar.gz", ] assert transfer["with"]["if-no-files-found"] == "error" + assert transfer["with"]["retention-days"] == 30 pypi_steps = jobs["publish-pypi"]["steps"] github_steps = jobs["publish-github"]["steps"] @@ -359,7 +381,10 @@ def test_release_workflow_publishes_the_attested_bytes_to_both_destinations(): verification_text = "\n".join( str(step.get("run", "")) for step in verification["steps"] ) + assert "release_platform_versions.py" in verification_text + assert '"${component_args[@]}"' in verification_text assert "generate_platform_manifest.py" in verification_text + assert "--require-compatible" in verification_text assert "--require-network" in verification_text assert "urllib.request.urlretrieve" in verification_text assert "gh release download" in verification_text diff --git a/tests/test_release_platform_versions.py b/tests/test_release_platform_versions.py new file mode 100644 index 000000000..4eb44e1a7 --- /dev/null +++ b/tests/test_release_platform_versions.py @@ -0,0 +1,87 @@ +from __future__ import annotations + +import importlib.util +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[1] +SCRIPT = ROOT / "scripts" / "release_platform_versions.py" +SPEC = importlib.util.spec_from_file_location("release_platform_versions", SCRIPT) +assert SPEC and SPEC.loader +MODULE = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(MODULE) + + +def _manifest() -> dict: + document = { + "manifest_kind": "openadapt-platform-release-manifest", + "schema_version": "2.0.0", + "components": { + role: {"package": package, "version": f"1.0.{index}"} + for index, (role, package) in enumerate( + MODULE.COMPONENT_PACKAGES.items(), start=1 + ) + }, + } + document["components"]["launcher"]["version"] = "2.0.0" + document["release_selection"] = { + "mode": "explicit-published", + "component_versions": { + role: component["version"] + for role, component in document["components"].items() + }, + } + return document + + +def test_release_uses_new_launcher_and_exact_reviewed_package_defaults() -> None: + versions = MODULE.release_component_versions(_manifest(), "2.0.0") + + assert versions == { + "launcher": "2.0.0", + "flow": "1.0.2", + "capture": "1.0.3", + "privacy": "1.0.4", + "types": "1.0.5", + "desktop": "1.0.6", + "agent": "1.0.7", + } + + +@pytest.mark.parametrize( + "mutation", ["missing", "extra", "package", "version", "dynamic", "selection"] +) +def test_release_refuses_an_open_or_invalid_package_selection(mutation: str) -> None: + document = _manifest() + if mutation == "missing": + document["components"].pop("agent") + elif mutation == "extra": + document["components"]["unknown"] = { + "package": "unknown", + "version": "1.0.0", + } + elif mutation == "package": + document["components"]["flow"]["package"] = "other-flow" + elif mutation == "version": + document["components"]["flow"]["version"] = "latest" + elif mutation == "dynamic": + document["release_selection"] = { + "mode": "latest-published", + "component_versions": {}, + } + else: + document["release_selection"]["component_versions"]["flow"] = "1.0.99" + + with pytest.raises(ValueError): + MODULE.release_component_versions(document, "2.0.0") + + +def test_release_refuses_a_nonstable_launcher_version() -> None: + with pytest.raises(ValueError, match="stable X.Y.Z"): + MODULE.release_component_versions(_manifest(), "2.0.0rc1") + + +def test_release_refuses_a_launcher_candidate_outside_the_exact_selection() -> None: + with pytest.raises(ValueError, match="does not match the candidate"): + MODULE.release_component_versions(_manifest(), "2.0.1") diff --git a/tests/test_verify_github_release.py b/tests/test_verify_github_release.py index 23d3ed585..966d99969 100644 --- a/tests/test_verify_github_release.py +++ b/tests/test_verify_github_release.py @@ -34,12 +34,15 @@ def release_candidate(tmp_path: Path) -> tuple[Path, Path, dict]: "tag_name": "v1.16.0", "draft": False, "prerelease": False, + "immutable": True, + "published_at": "2026-08-27T12:00:00Z", "author": {"login": "openadapt-release[bot]"}, "assets": [ { "name": path.name, "size": path.stat().st_size, "digest": f"sha256:{_sha256(path)}", + "uploader": {"login": "openadapt-release[bot]"}, } for path in (wheel, sdist) ], @@ -63,12 +66,27 @@ def test_exact_existing_release_is_idempotently_accepted(release_candidate): _verify(expected, downloaded, metadata) +def test_exact_draft_is_accepted_before_one_way_publication(release_candidate): + expected, downloaded, metadata = release_candidate + metadata.update(draft=True, immutable=False, published_at=None) + + MODULE.verify_release( + metadata, + expected_tag="v1.16.0", + expected_author="openadapt-release[bot]", + expected_dir=expected, + downloaded_dir=downloaded, + expected_state="draft", + ) + + @pytest.mark.parametrize( ("field", "value", "message"), [ ("tag_name", "v1.15.0", "release tag mismatch"), - ("draft", True, "is a draft"), + ("draft", True, "is not published"), ("prerelease", True, "is a prerelease"), + ("immutable", False, "is mutable"), ("author", {"login": "abrichr"}, "release author mismatch"), ], ) @@ -130,6 +148,14 @@ def test_release_asset_digest_mismatch_fails_closed(release_candidate): _verify(expected, downloaded, metadata) +def test_release_asset_uploader_mismatch_fails_closed(release_candidate): + expected, downloaded, metadata = release_candidate + metadata["assets"][0]["uploader"] = {"login": "abrichr"} + + with pytest.raises(ValueError, match="asset uploader mismatch"): + _verify(expected, downloaded, metadata) + + def test_downloaded_release_byte_mismatch_fails_closed(release_candidate): expected, downloaded, metadata = release_candidate name = metadata["assets"][0]["name"] @@ -139,3 +165,18 @@ def test_downloaded_release_byte_mismatch_fails_closed(release_candidate): with pytest.raises(ValueError, match="downloaded release asset bytes mismatch"): _verify(expected, downloaded, metadata) + + +def test_release_asset_directories_and_symlinks_fail_closed(release_candidate): + expected, downloaded, metadata = release_candidate + (downloaded / "nested").mkdir() + + with pytest.raises(ValueError, match="invalid entries: nested"): + _verify(expected, downloaded, metadata) + + (downloaded / "nested").rmdir() + name = metadata["assets"][0]["name"] + (downloaded / name).unlink() + (downloaded / name).symlink_to(expected / name) + with pytest.raises(ValueError, match="invalid entries"): + _verify(expected, downloaded, metadata) From b2711086996b82679ed837c0bb21a6836e01762f Mon Sep 17 00:00:00 2001 From: abrichr Date: Fri, 28 Aug 2026 16:16:15 -0400 Subject: [PATCH 08/10] docs: record published launcher-flow composition proof --- ...ublished-composition-1.16.0-flow-1.34.0.md | 177 ++++++++++++++++++ 1 file changed, 177 insertions(+) create mode 100644 docs/published-composition-1.16.0-flow-1.34.0.md diff --git a/docs/published-composition-1.16.0-flow-1.34.0.md b/docs/published-composition-1.16.0-flow-1.34.0.md new file mode 100644 index 000000000..92bc3e25a --- /dev/null +++ b/docs/published-composition-1.16.0-flow-1.34.0.md @@ -0,0 +1,177 @@ +# Published launcher and Flow composition, 2026-08-28 + +One macOS arm64 run installed the published `openadapt==1.16.0` and +`openadapt-flow==1.34.0` wheels together. The launcher completed its local +Standard tutorial, confirmed both declared effects through the independent +system-of-record interface, and caught a backend that showed success without +saving the record. + +This is package-composition evidence from one host. It is not a release +admission or a workflow admission. It doesn't establish Windows, Linux, or +customer-environment behavior. + +## Environment and artifacts + +- Test date: 2026-08-28 +- Host: macOS 15.7.3 (24G419), arm64 +- Python: 3.12.7 +- Launcher wheel: `openadapt-1.16.0-py3-none-any.whl` +- Launcher SHA-256: `371693e7607d1cdc1ea360ef5d657c1af791a0af39677fa2ce5933e7ba712719` +- Flow wheel: `openadapt_flow-1.34.0-py3-none-any.whl` +- Flow SHA-256: `56d32818989cb3a92830080ead39e10b08718c55e00988117f22cbfeaac98854` + +Both wheel hashes match the artifacts in +[`platform-manifest.json`](../platform-manifest.json) and the generated +[platform compatibility report](platform-compatibility-report.md). + +## Commands + +The commands below replace the run's disposable directory with +`published-composition-proof`. No repository package or source checkout was on +the test environment's import path. + +```bash +python3 -m pip download --only-binary=:all: --no-deps \ + --dest published-composition-proof/wheels \ + openadapt==1.16.0 openadapt-flow==1.34.0 + +python scripts/quickstart_lifecycle.py \ + --launcher-wheel published-composition-proof/wheels/openadapt-1.16.0-py3-none-any.whl \ + --flow-wheel published-composition-proof/wheels/openadapt_flow-1.34.0-py3-none-any.whl \ + --work-dir published-composition-proof/lifecycle \ + --source-revision published-openadapt-1.16.0-plus-flow-1.34.0 +``` + +The lifecycle script created a new virtual environment, installed the exact +wheels with the browser and hosted extras, ran the public launcher command, +checked the generated evidence, uninstalled both packages, and confirmed that +neither package remained importable. + +The fault run reinstalled only the same two wheels into that isolated +environment. It then ran: + +```bash +published-composition-proof/lifecycle/venv/bin/openadapt \ + quickstart --break-it \ + --out published-composition-proof/broken-case +``` + +The final checks used: + +```bash +published-composition-proof/lifecycle/venv/bin/openadapt version +published-composition-proof/lifecycle/venv/bin/python -m pip freeze +published-composition-proof/lifecycle/venv/bin/python -m pip check +published-composition-proof/lifecycle/venv/bin/python \ + -m pip uninstall -y openadapt openadapt-flow +``` + +After the last command, import probes for `openadapt` and `openadapt_flow` +returned no module. The `openadapt` and `openadapt-flow` console entry points +were also absent. + +## Healthy result + +The public launcher command returned these report values: + +| Field | Result | +|---|---:| +| Execution outcome | `VERIFIED` | +| Transaction outcome | `VERIFIED` | +| Execution profile | `standard` | +| Authorization contracts | 1/1 passed | +| Identity contracts | 5/5 passed | +| Postcondition contracts | 9/9 passed | +| Effect contracts | 2/2 confirmed | +| Effect evidence | Tier 1, independent system of record | +| Model calls | 0 | +| Receipt | Emitted and digest-bound | + +The local tutorial marked the verified transaction as billable. It did not +report or charge the local run. The clean report recorded bundle SHA-256 +`9c891c874f650bde15674e401f00350e2ecd952a99bfafa04d53da2d7a49e96c` +and receipt SHA-256 +`a526aecb9e1c30ac0f13931d26290b2677fce0774a35e956c6f0128be697a581`. + +The launcher delegated to Flow 1.34.0. `pip check` reported no broken +requirements. Flow lint reported no error and no warning for the generated +bundle. It reported five information notices about missing pixel identifier +crops because DOM identity owned those browser steps. + +## Broken result + +The `--break-it` command first ran the same certified bundle against the honest +backend. It then reran that bundle against a backend that showed an on-screen +success state but rejected the write. + +| Field | Result | +|---|---:| +| Execution outcome | `HALTED` | +| Transaction outcome | `RECONCILIATION_REQUIRED` | +| Transaction billable | `false` | +| Authorization contracts | 1/1 passed | +| Identity contracts | 5/5 passed | +| Postcondition contracts | 9/9 passed | +| Effect contracts | 0/2 passed | +| Retained effect evidence | One Tier 1 refutation before the halt | +| Model calls | 0 | +| Success receipt | Not emitted | + +The independent read found zero matching records. Flow halted at the +consequential step and retained the refutation. The clean and broken reports +both bind workflow contract SHA-256 +`955831cbb580f6f322e10d4f43a3224ea588b1f77c571a95328ebcafce2ac335`. + +## Resolved environment + +The isolated environment resolved these package versions: + +```text +annotated-types==0.8.0 +anyio==4.14.2 +certifi==2026.7.22 +cffi==2.1.1 +click==8.5.0 +cryptography==50.0.1 +flatbuffers==25.12.19 +greenlet==3.5.5 +h11==0.16.0 +httpcore==1.0.9 +httpx==0.28.1 +idna==3.19 +jaraco.classes==3.4.0 +jaraco.context==6.1.2 +jaraco.functools==4.6.0 +keyring==25.7.0 +more-itertools==11.1.0 +numpy==2.5.2 +onnxruntime==1.29.0 +openadapt==1.16.0 +openadapt-flow==1.34.0 +opencv-python==5.0.0.93 +packaging==26.3 +pillow==12.3.0 +playwright==1.62.0 +protobuf==7.36.0 +pyclipper==1.4.0 +pycparser==3.0 +pydantic==2.13.5 +pydantic_core==2.46.5 +pyee==13.0.1 +PyYAML==6.0.3 +rapidocr-onnxruntime==1.4.4 +shapely==2.1.2 +six==1.17.0 +tqdm==4.70.0 +typing-inspection==0.4.4 +typing_extensions==4.16.0 +``` + +## Limits + +This run used one macOS arm64 host and one Python version. It did not run the +Windows or Linux matrix. It did not exercise a customer application, Cloud +execution, Desktop, native capture, RDP, or Citrix. The model-call count comes +from Flow's retained report. This run did not perform an independent packet +capture. A release or workflow admission needs its own signed evidence, +validity period, revocation state, and required trial counts. From 663463636313bf2dd23e116afa86211f734cba06 Mon Sep 17 00:00:00 2001 From: abrichr Date: Fri, 28 Aug 2026 17:06:48 -0400 Subject: [PATCH 09/10] ci: gate launcher release publication --- .github/workflows/release-and-publish.yml | 115 ++++++--- ...ublished-composition-1.16.0-flow-1.34.0.md | 24 +- scripts/check_source_boundary.py | 221 +++++++++++++++++- scripts/release_platform_versions.py | 12 +- scripts/verify_release_hosting.py | 191 +++++++++++++++ tests/test_release_hosting.py | 85 +++++++ tests/test_release_lock.py | 88 ++++++- tests/test_release_platform_versions.py | 13 +- tests/test_source_boundary.py | 94 ++++++++ 9 files changed, 798 insertions(+), 45 deletions(-) create mode 100644 scripts/verify_release_hosting.py create mode 100644 tests/test_release_hosting.py diff --git a/.github/workflows/release-and-publish.yml b/.github/workflows/release-and-publish.yml index c11a1f826..51091511e 100644 --- a/.github/workflows/release-and-publish.yml +++ b/.github/workflows/release-and-publish.yml @@ -5,9 +5,10 @@ name: Release and PyPI Publish # the annotated tag and its matching GitHub Release. It cannot push a version # commit or any other main commit. # -# A protected v* tag starts the build, attestation, PyPI Trusted Publishing, -# GitHub Release, and publication-verification jobs. Rerun that exact tag run -# after a partial publication failure. Do not create a recovery tag. +# An annotated v* tag created by the release App starts the build, attestation, +# PyPI Trusted Publishing, GitHub Release, and publication-verification jobs. +# Rerun that exact tag run after a partial publication failure. Do not create a +# recovery tag. on: workflow_dispatch: @@ -45,7 +46,9 @@ jobs: private-key: ${{ secrets.OPENADAPT_RELEASE_APP_PRIVATE_KEY }} owner: ${{ github.repository_owner }} repositories: ${{ github.event.repository.name }} + permission-administration: read permission-contents: write + permission-metadata: read - name: Require the exact release App identity for tag creation env: @@ -73,6 +76,7 @@ jobs: - name: Require an exact reviewed release candidate id: candidate env: + GH_TOKEN: ${{ steps.release-app.outputs.token }} REQUESTED_VERSION: ${{ inputs.version }} run: | set -euo pipefail @@ -125,6 +129,19 @@ jobs: ) PY python scripts/verify_release_lock.py + python scripts/release_platform_versions.py \ + --manifest platform-manifest.json \ + --launcher-version "$project_version" \ + > /tmp/release-component-versions.txt + python scripts/validate_platform_manifest.py \ + --manifest platform-manifest.json \ + --report docs/platform-compatibility-report.md \ + --offline \ + --require-compatible + python scripts/check_source_boundary.py + python scripts/verify_release_hosting.py \ + --repository "$GITHUB_REPOSITORY" \ + --tag "$tag" git fetch --force --tags origin if git show-ref --verify --quiet "refs/tags/$tag"; then @@ -224,6 +241,15 @@ jobs: ) PY python scripts/verify_release_lock.py + python scripts/release_platform_versions.py \ + --manifest platform-manifest.json \ + --launcher-version "$project_version" \ + > /tmp/release-component-versions.txt + python scripts/validate_platform_manifest.py \ + --manifest platform-manifest.json \ + --report docs/platform-compatibility-report.md \ + --offline \ + --require-compatible - name: Install uv uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 @@ -238,6 +264,7 @@ jobs: run: | uv build --wheel --sdist python scripts/verify_release_artifacts.py + python scripts/check_source_boundary.py --dist dist - name: Attest release artifacts uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2 @@ -256,10 +283,58 @@ jobs: if-no-files-found: error retention-days: 30 - publish-pypi: + preflight-github: needs: build-and-attest if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') runs-on: ubuntu-latest + environment: release-identity + permissions: + contents: read + + steps: + - name: Checkout the exact release tag + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.ref }} + persist-credentials: false + + - name: Set up Python + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.12" + + - name: Create a repository-scoped release App token for the preflight + id: release-app + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + app-id: ${{ vars.OPENADAPT_RELEASE_APP_ID }} + private-key: ${{ secrets.OPENADAPT_RELEASE_APP_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + repositories: ${{ github.event.repository.name }} + permission-administration: read + permission-contents: read + permission-metadata: read + + - name: Require the GitHub publication identity and hosting controls + env: + GH_TOKEN: ${{ steps.release-app.outputs.token }} + ACTUAL_APP_SLUG: ${{ steps.release-app.outputs.app-slug }} + EXPECTED_APP_SLUG: openadapt-release + RELEASE_TAG: ${{ github.ref_name }} + run: | + set -euo pipefail + if [ "$ACTUAL_APP_SLUG" != "$EXPECTED_APP_SLUG" ]; then + echo "GitHub publication requires the $EXPECTED_APP_SLUG App, not $ACTUAL_APP_SLUG." >&2 + exit 1 + fi + python scripts/verify_release_hosting.py \ + --repository "$GITHUB_REPOSITORY" \ + --tag "$RELEASE_TAG" + + publish-pypi: + needs: [build-and-attest, preflight-github] + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') + runs-on: ubuntu-latest environment: pypi permissions: contents: read @@ -311,7 +386,7 @@ jobs: --poll-seconds 10 publish-github: - needs: [build-and-attest, publish-pypi] + needs: [build-and-attest, preflight-github, publish-pypi] if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') runs-on: ubuntu-latest environment: release-identity @@ -408,28 +483,9 @@ jobs: exit 1 fi - immutable_url="$GITHUB_API_URL/repos/$GITHUB_REPOSITORY/immutable-releases" - immutable_status="$(curl --silent --show-error \ - --output /tmp/immutable-releases.json \ - --write-out '%{http_code}' \ - --header "Accept: application/vnd.github+json" \ - --header "Authorization: Bearer $GH_TOKEN" \ - --header "X-GitHub-Api-Version: 2026-03-10" \ - "$immutable_url")" - if [ "$immutable_status" != "200" ]; then - echo "GitHub immutable releases are unavailable (HTTP $immutable_status)." >&2 - exit 1 - fi - python - <<'PY' - import json - from pathlib import Path - - document = json.loads( - Path("/tmp/immutable-releases.json").read_text(encoding="utf-8") - ) - if document.get("enabled") is not True: - raise SystemExit("GitHub immutable releases are not enabled.") - PY + python scripts/verify_release_hosting.py \ + --repository "$GITHUB_REPOSITORY" \ + --tag "$RELEASE_TAG" release_url="$GITHUB_API_URL/repos/$GITHUB_REPOSITORY/releases/tags/$RELEASE_TAG" release_status="$(curl --silent --show-error \ @@ -715,6 +771,7 @@ jobs: needs: - create-release-tag - build-and-attest + - preflight-github - publish-pypi - publish-github - verify-publication @@ -722,6 +779,7 @@ jobs: ${{ always() && (needs.create-release-tag.result == 'failure' || needs.build-and-attest.result == 'failure' || + needs.preflight-github.result == 'failure' || needs.publish-pypi.result == 'failure' || needs.publish-github.result == 'failure' || needs.verify-publication.result == 'failure') }} @@ -735,6 +793,7 @@ jobs: GH_TOKEN: ${{ github.token }} CREATE_TAG_RESULT: ${{ needs.create-release-tag.result }} BUILD_RESULT: ${{ needs.build-and-attest.result }} + GITHUB_PREFLIGHT_RESULT: ${{ needs.preflight-github.result }} PYPI_RESULT: ${{ needs.publish-pypi.result }} GITHUB_RESULT: ${{ needs.publish-github.result }} VERIFY_RESULT: ${{ needs.verify-publication.result }} @@ -743,7 +802,7 @@ jobs: TITLE="Release workflow failed" BODY="The release workflow failed: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} - create-tag=${CREATE_TAG_RESULT}, build-and-attest=${BUILD_RESULT}, publish-pypi=${PYPI_RESULT}, publish-github=${GITHUB_RESULT}, verify-publication=${VERIFY_RESULT}. + create-tag=${CREATE_TAG_RESULT}, build-and-attest=${BUILD_RESULT}, preflight-github=${GITHUB_PREFLIGHT_RESULT}, publish-pypi=${PYPI_RESULT}, publish-github=${GITHUB_RESULT}, verify-publication=${VERIFY_RESULT}. If the annotated tag exists, rerun only the failed jobs in this run with gh run rerun ${{ github.run_id }} --failed. Do not start a new full run or create a recovery tag." existing="$(gh issue list --repo "$GITHUB_REPOSITORY" --state open \ diff --git a/docs/published-composition-1.16.0-flow-1.34.0.md b/docs/published-composition-1.16.0-flow-1.34.0.md index 92bc3e25a..d7e5fc035 100644 --- a/docs/published-composition-1.16.0-flow-1.34.0.md +++ b/docs/published-composition-1.16.0-flow-1.34.0.md @@ -15,6 +15,10 @@ customer-environment behavior. - Test date: 2026-08-28 - Host: macOS 15.7.3 (24G419), arm64 - Python: 3.12.7 +- Lifecycle script source: launcher tag `v1.16.0`, commit + `089c27c046f5cd972d299361f9d68285c1896c71` +- Lifecycle script SHA-256: + `da8dfd1c292af5d1dbb32880c9b3846c0a79aedec41e2427af9d2686eee8dcbc` - Launcher wheel: `openadapt-1.16.0-py3-none-any.whl` - Launcher SHA-256: `371693e7607d1cdc1ea360ef5d657c1af791a0af39677fa2ce5933e7ba712719` - Flow wheel: `openadapt_flow-1.34.0-py3-none-any.whl` @@ -31,11 +35,25 @@ The commands below replace the run's disposable directory with the test environment's import path. ```bash -python3 -m pip download --only-binary=:all: --no-deps \ +git clone https://github.com/OpenAdaptAI/OpenAdapt.git published-composition-source +git -C published-composition-source checkout --detach \ + 089c27c046f5cd972d299361f9d68285c1896c71 +printf '%s %s\n' \ + da8dfd1c292af5d1dbb32880c9b3846c0a79aedec41e2427af9d2686eee8dcbc \ + published-composition-source/scripts/quickstart_lifecycle.py \ + | shasum -a 256 -c - + +python3.12 -m pip download --only-binary=:all: --no-deps \ --dest published-composition-proof/wheels \ openadapt==1.16.0 openadapt-flow==1.34.0 - -python scripts/quickstart_lifecycle.py \ +printf '%s %s\n%s %s\n' \ + 371693e7607d1cdc1ea360ef5d657c1af791a0af39677fa2ce5933e7ba712719 \ + published-composition-proof/wheels/openadapt-1.16.0-py3-none-any.whl \ + 56d32818989cb3a92830080ead39e10b08718c55e00988117f22cbfeaac98854 \ + published-composition-proof/wheels/openadapt_flow-1.34.0-py3-none-any.whl \ + | shasum -a 256 -c - + +python3.12 published-composition-source/scripts/quickstart_lifecycle.py \ --launcher-wheel published-composition-proof/wheels/openadapt-1.16.0-py3-none-any.whl \ --flow-wheel published-composition-proof/wheels/openadapt_flow-1.34.0-py3-none-any.whl \ --work-dir published-composition-proof/lifecycle \ diff --git a/scripts/check_source_boundary.py b/scripts/check_source_boundary.py index a859a6e09..1b9ba4768 100644 --- a/scripts/check_source_boundary.py +++ b/scripts/check_source_boundary.py @@ -36,10 +36,10 @@ denylisted regular expression, or carry a private-artifact banner, fails. It complements, and deliberately overlaps with, the packaging-time guard in -openadapt-flow's scripts/check_release_consistency.py (which inspects built -wheels/sdists and reads the same rendered policy). This script inspects the -REPOSITORY TREE, so the leak is caught at PR time in any public core repo, not -only when an artifact is built. +openadapt-flow's scripts/check_release_consistency.py. This script inspects the +repository tree and, when ``--dist`` is set, the actual wheel and source archive +members. This catches a leak at PR time and again after the build tool generates +the release artifacts. Files that legitimately DISCUSS the boundary (this script, the rendered policy itself, policy docs, contributor docs) are covered by the allowlist below. That @@ -48,6 +48,7 @@ Usage: python scripts/check_source_boundary.py [--root PATH] [--repo NAME] + [--dist PATH] --root defaults to this repository. Point it at another checkout to run the same gate in that repo's CI without vendoring a divergent copy; the rules are @@ -59,8 +60,11 @@ import argparse import json import re +import stat import subprocess import sys +import tarfile +import zipfile from pathlib import Path DEFAULT_ROOT = Path(__file__).resolve().parents[1] @@ -100,6 +104,9 @@ } MAX_CONTENT_BYTES = 5 * 1024 * 1024 +MAX_ARTIFACT_MEMBER_BYTES = 20 * 1024 * 1024 +MAX_ARTIFACT_TOTAL_BYTES = 100 * 1024 * 1024 +MAX_ARTIFACT_MEMBERS = 10_000 class PolicyError(RuntimeError): @@ -141,6 +148,16 @@ def __init__(self, document: dict) -> None: _require_strings(enforcement, "private_path_segments", where="enforcement") ) + built = enforcement.get("built_artifacts") + if not isinstance(built, dict): + raise PolicyError("enforcement.built_artifacts: block is missing") + self.built_artifact_path_prefixes = tuple( + prefix.rstrip("/") + for prefix in _require_strings( + built, "path_prefixes", where="enforcement.built_artifacts" + ) + ) + tree = enforcement.get("repository_tree") if not isinstance(tree, dict): raise PolicyError("enforcement.repository_tree: block is missing") @@ -295,6 +312,188 @@ def scan(root: Path, policy: SourcePolicy) -> list[str]: return violations +def _safe_archive_member_name(name: str, *, directory: bool) -> str | None: + candidate = name[:-1] if directory and name.endswith("/") else name + if ( + not candidate + or candidate.startswith("/") + or "\\" in candidate + or (len(candidate) >= 2 and candidate[0].isalpha() and candidate[1] == ":") + ): + return None + if any(part in {"", ".", ".."} for part in candidate.split("/")): + return None + return candidate + + +def _artifact_path_violations( + artifact: str, member: str, policy: SourcePolicy +) -> list[str]: + violations: list[str] = [] + lower = member.lower() + token = next((item for item in policy.path_tokens if item in lower), None) + if token is not None: + violations.append( + f"{artifact}:{member}: path contains denylisted token {token!r}" + ) + segment = next( + (part for part in lower.split("/") if part in policy.private_path_segments), + None, + ) + if segment is not None: + violations.append( + f"{artifact}:{member}: path lies under private segment {segment!r}" + ) + prefix = next( + ( + item + for item in policy.built_artifact_path_prefixes + if lower == item + or lower.startswith(item + "/") + or ("/" + item + "/") in ("/" + lower + "/") + ), + None, + ) + if prefix is not None: + violations.append( + f"{artifact}:{member}: path matches private artifact prefix {prefix!r}" + ) + return violations + + +def _artifact_content_violations( + artifact: str, member: str, raw: bytes, policy: SourcePolicy +) -> list[str]: + for signature in policy.content_signatures: + if signature.encode("utf-8") in raw: + return [f"{artifact}:{member}: content carries the private-artifact banner"] + text = raw.decode("utf-8", errors="ignore") + match = policy.content_regex.search(text) + if match: + line = text.count("\n", 0, match.start()) + 1 + return [ + f"{artifact}:{member}:{line}: content matches denylisted pattern " + f"{match.group(0)!r}" + ] + return [] + + +def _scan_zip_artifact(path: Path, policy: SourcePolicy) -> list[str]: + violations: list[str] = [] + seen: set[str] = set() + total_bytes = 0 + with zipfile.ZipFile(path) as archive: + members = archive.infolist() + if len(members) > MAX_ARTIFACT_MEMBERS: + return [f"{path.name}: archive contains too many members"] + for info in members: + member = _safe_archive_member_name(info.filename, directory=info.is_dir()) + if member is None: + violations.append(f"{path.name}:{info.filename}: unsafe archive path") + continue + if member in seen: + violations.append(f"{path.name}:{member}: duplicate archive path") + continue + seen.add(member) + violations.extend(_artifact_path_violations(path.name, member, policy)) + + mode = (info.external_attr >> 16) & 0xFFFF + entry_type = stat.S_IFMT(mode) + if info.is_dir(): + if entry_type not in {0, stat.S_IFDIR}: + violations.append( + f"{path.name}:{member}: directory has an invalid entry type" + ) + continue + if entry_type not in {0, stat.S_IFREG}: + violations.append( + f"{path.name}:{member}: symlink or special entry is not permitted" + ) + continue + if info.flag_bits & 0x1: + violations.append( + f"{path.name}:{member}: encrypted archive entry is not permitted" + ) + continue + if info.file_size > MAX_ARTIFACT_MEMBER_BYTES: + violations.append(f"{path.name}:{member}: archive member is too large") + continue + total_bytes += info.file_size + if total_bytes > MAX_ARTIFACT_TOTAL_BYTES: + violations.append(f"{path.name}: expanded archive is too large") + break + violations.extend( + _artifact_content_violations( + path.name, member, archive.read(info), policy + ) + ) + return violations + + +def _scan_tar_artifact(path: Path, policy: SourcePolicy) -> list[str]: + violations: list[str] = [] + seen: set[str] = set() + total_bytes = 0 + with tarfile.open(path, mode="r:gz") as archive: + members = archive.getmembers() + if len(members) > MAX_ARTIFACT_MEMBERS: + return [f"{path.name}: archive contains too many members"] + for info in members: + member = _safe_archive_member_name(info.name, directory=info.isdir()) + if member is None: + violations.append(f"{path.name}:{info.name}: unsafe archive path") + continue + if member in seen: + violations.append(f"{path.name}:{member}: duplicate archive path") + continue + seen.add(member) + violations.extend(_artifact_path_violations(path.name, member, policy)) + if info.isdir(): + continue + if not info.isfile(): + violations.append( + f"{path.name}:{member}: symlink or special entry is not permitted" + ) + continue + if info.size > MAX_ARTIFACT_MEMBER_BYTES: + violations.append(f"{path.name}:{member}: archive member is too large") + continue + total_bytes += info.size + if total_bytes > MAX_ARTIFACT_TOTAL_BYTES: + violations.append(f"{path.name}: expanded archive is too large") + break + stream = archive.extractfile(info) + if stream is None: + violations.append(f"{path.name}:{member}: archive member is unreadable") + continue + violations.extend( + _artifact_content_violations(path.name, member, stream.read(), policy) + ) + return violations + + +def scan_built_artifacts(dist: Path, policy: SourcePolicy) -> list[str]: + """Return policy and archive-structure violations in built distributions.""" + if dist.is_symlink() or not dist.is_dir(): + raise PolicyError(f"built artifact directory is missing or invalid: {dist}") + artifacts = sorted( + path + for path in dist.iterdir() + if path.name.endswith(".whl") or path.name.endswith(".tar.gz") + ) + if not artifacts: + raise PolicyError(f"built artifact directory has no wheel or sdist: {dist}") + violations: list[str] = [] + for path in artifacts: + if path.is_symlink() or not path.is_file(): + violations.append(f"{path.name}: artifact is not a regular file") + elif path.name.endswith(".whl"): + violations.extend(_scan_zip_artifact(path, policy)) + else: + violations.extend(_scan_tar_artifact(path, policy)) + return violations + + def main() -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument( @@ -317,6 +516,12 @@ def main() -> int: default=POLICY_PATH, help="Rendered policy to enforce (default: the copy in this repository).", ) + parser.add_argument( + "--dist", + type=Path, + default=None, + help="Built wheel and source archive directory to scan after the tree.", + ) args = parser.parse_args() root = args.root.resolve() @@ -341,9 +546,14 @@ def main() -> int: try: violations = scan(root, policy) + if args.dist is not None: + violations.extend(scan_built_artifacts(args.dist.resolve(), policy)) except subprocess.CalledProcessError as exc: print(f"FATAL: git ls-files failed in {root}: {exc}", file=sys.stderr) return 2 + except (OSError, PolicyError, tarfile.TarError, zipfile.BadZipFile) as exc: + print(f"FATAL: built artifact scan failed: {exc}", file=sys.stderr) + return 2 if violations: print( @@ -358,7 +568,8 @@ def main() -> int: return 1 print( - f"OK: no boundary violations in {name} " + f"OK: no boundary violations in {name}" + f"{' and its built artifacts' if args.dist is not None else ''} " f"(policy {policy.policy_digest}, updated {policy.policy_last_updated})." ) return 0 diff --git a/scripts/release_platform_versions.py b/scripts/release_platform_versions.py index 91321be5b..45f459f76 100644 --- a/scripts/release_platform_versions.py +++ b/scripts/release_platform_versions.py @@ -31,7 +31,7 @@ def _object(value: Any, context: str) -> dict[str, Any]: def release_component_versions( document: dict[str, Any], launcher_version: str ) -> dict[str, str]: - """Return the closed seven-package selection for the release transaction.""" + """Return the launcher candidate and exact reviewed published dependencies.""" if document.get("manifest_kind") != MANIFEST_KIND: raise ValueError("platform manifest kind is invalid") @@ -69,8 +69,14 @@ def release_component_versions( if selected_versions.get(role) != version: raise ValueError(f"{role} release selection does not match its component") versions[role] = version - if versions["launcher"] != launcher_version: - raise ValueError("launcher release selection does not match the candidate") + published_launcher = versions["launcher"] + published_parts = tuple(int(part) for part in published_launcher.split(".")) + candidate_parts = tuple(int(part) for part in launcher_version.split(".")) + if candidate_parts < published_parts: + raise ValueError( + "launcher candidate must not be older than the published platform launcher" + ) + versions["launcher"] = launcher_version return versions diff --git a/scripts/verify_release_hosting.py b/scripts/verify_release_hosting.py new file mode 100644 index 000000000..edc0371ba --- /dev/null +++ b/scripts/verify_release_hosting.py @@ -0,0 +1,191 @@ +#!/usr/bin/env python3 +"""Verify GitHub controls required before an immutable release transaction.""" + +from __future__ import annotations + +import argparse +import json +import os +import re +import urllib.parse +import urllib.request +from typing import Any + +REPOSITORY = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$") +RELEASE_TAG = re.compile(r"^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$") +REQUIRED_TAG_RULES = frozenset({"creation", "update", "deletion"}) + + +def _object(value: Any, context: str) -> dict[str, Any]: + if not isinstance(value, dict): + raise ValueError(f"{context} must be an object") + return value + + +def _pattern_matches(pattern: str, ref: str) -> bool: + if pattern == "~ALL": + return True + if "[" in pattern or "]" in pattern: + raise ValueError( + "ruleset ref patterns with character sets are unsupported by this gate" + ) + pieces: list[str] = [] + index = 0 + while index < len(pattern): + character = pattern[index] + if character == "*": + if index + 1 < len(pattern) and pattern[index + 1] == "*": + while index + 1 < len(pattern) and pattern[index + 1] == "*": + index += 1 + pieces.append(".*") + else: + pieces.append("[^/]*") + elif character == "?": + pieces.append("[^/]") + else: + pieces.append(re.escape(character)) + index += 1 + return re.fullmatch("".join(pieces), ref) is not None + + +def _ruleset_applies(document: dict[str, Any], ref: str) -> bool: + if document.get("target") != "tag" or document.get("enforcement") != "active": + return False + conditions = _object(document.get("conditions"), "ruleset conditions") + ref_name = _object(conditions.get("ref_name"), "ruleset ref_name condition") + include = ref_name.get("include") + exclude = ref_name.get("exclude") + if not isinstance(include, list) or not all( + isinstance(pattern, str) for pattern in include + ): + raise ValueError("ruleset include patterns must be a list of strings") + if not isinstance(exclude, list) or not all( + isinstance(pattern, str) for pattern in exclude + ): + raise ValueError("ruleset exclude patterns must be a list of strings") + return any(_pattern_matches(pattern, ref) for pattern in include) and not any( + _pattern_matches(pattern, ref) for pattern in exclude + ) + + +def verify_release_hosting_documents( + immutable: dict[str, Any], rulesets: list[dict[str, Any]], tag: str +) -> None: + """Validate immutable releases and active creation/update/deletion tag rules.""" + if RELEASE_TAG.fullmatch(tag) is None: + raise ValueError("release tag must be an exact stable vX.Y.Z value") + if immutable.get("enabled") is not True: + raise ValueError("GitHub immutable releases are not enabled") + + ref = f"refs/tags/{tag}" + active_rules: set[str] = set() + for index, ruleset in enumerate(rulesets): + document = _object(ruleset, f"ruleset {index}") + if not _ruleset_applies(document, ref): + continue + rules = document.get("rules") + if not isinstance(rules, list): + raise ValueError("ruleset rules must be a list") + for rule in rules: + item = _object(rule, "ruleset rule") + rule_type = item.get("type") + if isinstance(rule_type, str): + active_rules.add(rule_type) + + missing = sorted(REQUIRED_TAG_RULES - active_rules) + if missing: + raise ValueError(f"{ref} lacks active tag rules: {', '.join(missing)}") + + +def _get_json(url: str, token: str) -> tuple[Any, int]: + request = urllib.request.Request( + url, + headers={ + "Accept": "application/vnd.github+json", + "Authorization": f"Bearer {token}", + "X-GitHub-Api-Version": "2026-03-10", + }, + ) + with urllib.request.urlopen(request, timeout=30) as response: # noqa: S310 + return json.load(response), response.status + + +def load_release_hosting_documents( + repository: str, *, api_url: str, token: str +) -> tuple[dict[str, Any], list[dict[str, Any]]]: + if REPOSITORY.fullmatch(repository) is None: + raise ValueError("repository must have the owner/name form") + base = api_url.rstrip("/") + repository_path = "/".join( + urllib.parse.quote(part, safe="") for part in repository.split("/") + ) + immutable_raw, immutable_status = _get_json( + f"{base}/repos/{repository_path}/immutable-releases", token + ) + if immutable_status != 200: + raise ValueError( + f"GitHub immutable release query returned HTTP {immutable_status}" + ) + immutable = _object(immutable_raw, "immutable release response") + + summaries: list[dict[str, Any]] = [] + page = 1 + while True: + raw, status = _get_json( + f"{base}/repos/{repository_path}/rulesets" + f"?includes_parents=true&targets=tag&per_page=100&page={page}", + token, + ) + if status != 200: + raise ValueError(f"GitHub ruleset query returned HTTP {status}") + if not isinstance(raw, list): + raise ValueError("GitHub ruleset response must be a list") + summaries.extend(_object(item, "ruleset summary") for item in raw) + if len(raw) < 100: + break + page += 1 + + details: list[dict[str, Any]] = [] + seen_urls: set[str] = set() + for summary in summaries: + if summary.get("enforcement") != "active": + continue + links = _object(summary.get("_links"), "ruleset summary links") + self_link = _object(links.get("self"), "ruleset self link") + url = self_link.get("href") + if not isinstance(url, str) or not url.startswith(base + "/"): + raise ValueError("ruleset self link is missing or outside the GitHub API") + if url in seen_urls: + continue + seen_urls.add(url) + raw, status = _get_json(url, token) + if status != 200: + raise ValueError(f"GitHub ruleset detail query returned HTTP {status}") + details.append(_object(raw, "ruleset detail")) + return immutable, details + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--repository", required=True) + parser.add_argument("--tag", required=True) + parser.add_argument( + "--api-url", default=os.environ.get("GITHUB_API_URL", "https://api.github.com") + ) + args = parser.parse_args() + token = os.environ.get("GH_TOKEN", "") + if not token: + parser.exit(1, "GH_TOKEN is required\n") + try: + immutable, rulesets = load_release_hosting_documents( + args.repository, api_url=args.api_url, token=token + ) + verify_release_hosting_documents(immutable, rulesets, args.tag) + except (OSError, ValueError, json.JSONDecodeError) as exc: + parser.exit(1, f"{exc}\n") + print(f"OK: GitHub immutable releases and tag rules protect {args.tag}.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_release_hosting.py b/tests/test_release_hosting.py new file mode 100644 index 000000000..414c022e8 --- /dev/null +++ b/tests/test_release_hosting.py @@ -0,0 +1,85 @@ +from __future__ import annotations + +import importlib.util +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[1] +SCRIPT = ROOT / "scripts" / "verify_release_hosting.py" +SPEC = importlib.util.spec_from_file_location("verify_release_hosting", SCRIPT) +assert SPEC and SPEC.loader +MODULE = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(MODULE) + + +def _ruleset(*rule_types: str, enforcement: str = "active") -> dict: + return { + "target": "tag", + "enforcement": enforcement, + "conditions": {"ref_name": {"include": ["refs/tags/v*"], "exclude": []}}, + "rules": [{"type": rule_type} for rule_type in rule_types], + } + + +def test_release_hosting_accepts_immutable_release_and_closed_tag_rules() -> None: + MODULE.verify_release_hosting_documents( + {"enabled": True}, + [_ruleset("creation"), _ruleset("update", "deletion")], + "v2.0.0", + ) + + +def test_release_hosting_refuses_disabled_immutable_releases() -> None: + with pytest.raises(ValueError, match="immutable releases are not enabled"): + MODULE.verify_release_hosting_documents( + {"enabled": False}, + [_ruleset("creation", "update", "deletion")], + "v2.0.0", + ) + + +def test_release_hosting_refuses_missing_tag_rule() -> None: + with pytest.raises(ValueError, match="deletion"): + MODULE.verify_release_hosting_documents( + {"enabled": True}, [_ruleset("creation", "update")], "v2.0.0" + ) + + +def test_release_hosting_ignores_disabled_or_nonmatching_rulesets() -> None: + excluded = _ruleset("creation", "update", "deletion") + excluded["conditions"]["ref_name"]["exclude"] = ["refs/tags/v2.0.0"] + with pytest.raises(ValueError, match="creation, deletion, update"): + MODULE.verify_release_hosting_documents( + {"enabled": True}, + [ + _ruleset("creation", "update", "deletion", enforcement="disabled"), + excluded, + ], + "v2.0.0", + ) + + +def test_release_hosting_requires_a_stable_tag() -> None: + with pytest.raises(ValueError, match="stable vX.Y.Z"): + MODULE.verify_release_hosting_documents( + {"enabled": True}, [_ruleset("creation", "update", "deletion")], "v2.0" + ) + + +def test_release_hosting_does_not_match_a_star_across_ref_segments() -> None: + broad = _ruleset("creation", "update", "deletion") + broad["conditions"]["ref_name"]["include"] = ["refs/*"] + + with pytest.raises(ValueError, match="creation, deletion, update"): + MODULE.verify_release_hosting_documents({"enabled": True}, [broad], "v2.0.0") + + +def test_release_hosting_fails_closed_on_an_unsupported_exclude_pattern() -> None: + ambiguous = _ruleset("creation", "update", "deletion") + ambiguous["conditions"]["ref_name"]["exclude"] = ["refs/tags/v[0-9]*"] + + with pytest.raises(ValueError, match="character sets are unsupported"): + MODULE.verify_release_hosting_documents( + {"enabled": True}, [ambiguous], "v2.0.0" + ) diff --git a/tests/test_release_lock.py b/tests/test_release_lock.py index 1cdd67335..e7003b1fe 100644 --- a/tests/test_release_lock.py +++ b/tests/test_release_lock.py @@ -143,7 +143,9 @@ def test_release_workflow_app_creates_only_an_exact_reviewed_tag(): "private-key": "${{ secrets.OPENADAPT_RELEASE_APP_PRIVATE_KEY }}", "owner": "${{ github.repository_owner }}", "repositories": "${{ github.event.repository.name }}", + "permission-administration": "read", "permission-contents": "write", + "permission-metadata": "read", } identity = next( step @@ -166,6 +168,8 @@ def test_release_workflow_app_creates_only_an_exact_reviewed_tag(): assert 'current_main" != "$GITHUB_SHA' in candidate["run"] assert 'REQUESTED_VERSION" != "$project_version' in candidate["run"] assert "CHANGELOG.md must start with" in candidate["run"] + assert "scripts/verify_release_hosting.py" in candidate["run"] + assert candidate["env"]["GH_TOKEN"] == "${{ steps.release-app.outputs.token }}" assert "Tag $tag already exists" in candidate["run"] tag = next( @@ -207,6 +211,43 @@ def test_release_workflow_rechecks_main_immediately_before_the_app_tag_push(): ) +def test_release_workflow_checks_the_platform_selection_before_any_release_write(): + workflow_path = ROOT / ".github/workflows/release-and-publish.yml" + document = yaml.safe_load(workflow_path.read_text(encoding="utf-8")) + jobs = document["jobs"] + + create = jobs["create-release-tag"] + candidate = next(step for step in create["steps"] if step.get("id") == "candidate") + tag = next( + step + for step in create["steps"] + if step["name"] == "Create and push only the annotated release tag" + ) + assert "scripts/release_platform_versions.py" in candidate["run"] + assert "--manifest platform-manifest.json" in candidate["run"] + assert '--launcher-version "$project_version"' in candidate["run"] + assert "scripts/validate_platform_manifest.py" in candidate["run"] + assert "--offline" in candidate["run"] + assert "--require-compatible" in candidate["run"] + assert "scripts/check_source_boundary.py" in candidate["run"] + assert create["steps"].index(candidate) < create["steps"].index(tag) + + build = jobs["build-and-attest"] + guard = next( + step + for step in build["steps"] + if step["name"] == "Require the release App tag and exact candidate state" + ) + artifact_build = next( + step + for step in build["steps"] + if step["name"] == "Build exact release artifacts" + ) + assert "scripts/release_platform_versions.py" in guard["run"] + assert "scripts/validate_platform_manifest.py" in guard["run"] + assert build["steps"].index(guard) < build["steps"].index(artifact_build) + + def test_release_workflow_publishes_from_the_exact_app_tag_with_oidc(): workflow_path = ROOT / ".github/workflows/release-and-publish.yml" document = yaml.safe_load(workflow_path.read_text(encoding="utf-8")) @@ -226,6 +267,7 @@ def test_release_workflow_publishes_from_the_exact_app_tag_with_oidc(): assert "git merge-base --is-ancestor HEAD refs/remotes/origin/main" in guard["run"] pypi = jobs["publish-pypi"] + assert pypi["needs"] == ["build-and-attest", "preflight-github"] assert pypi["environment"] == "pypi" publish = next( step for step in pypi["steps"] if step["name"].startswith("Publish to PyPI") @@ -249,7 +291,39 @@ def test_release_workflow_publishes_from_the_exact_app_tag_with_oidc(): assert "scripts/verify_pypi_release.py" in strict["run"] assert "--allow-matching-subset" not in strict["run"] - assert jobs["publish-github"]["environment"] == "release-identity" + preflight = jobs["preflight-github"] + assert preflight["needs"] == "build-and-attest" + assert preflight["environment"] == "release-identity" + preflight_steps = preflight["steps"] + preflight_app = next( + step for step in preflight_steps if step.get("id") == "release-app" + ) + assert preflight_app["with"] == { + "app-id": "${{ vars.OPENADAPT_RELEASE_APP_ID }}", + "private-key": "${{ secrets.OPENADAPT_RELEASE_APP_PRIVATE_KEY }}", + "owner": "${{ github.repository_owner }}", + "repositories": "${{ github.event.repository.name }}", + "permission-administration": "read", + "permission-contents": "read", + "permission-metadata": "read", + } + preflight_guard = next( + step + for step in preflight_steps + if step["name"] + == "Require the GitHub publication identity and hosting controls" + ) + assert "scripts/verify_release_hosting.py" in preflight_guard["run"] + assert '--repository "$GITHUB_REPOSITORY"' in preflight_guard["run"] + assert '--tag "$RELEASE_TAG"' in preflight_guard["run"] + + github = jobs["publish-github"] + assert github["needs"] == [ + "build-and-attest", + "preflight-github", + "publish-pypi", + ] + assert github["environment"] == "release-identity" publish_steps = jobs["publish-github"]["steps"] app = next(step for step in publish_steps if step.get("id") == "release-app") assert app["uses"].startswith("actions/create-github-app-token@") @@ -301,8 +375,7 @@ def test_release_workflow_publishes_from_the_exact_app_tag_with_oidc(): assert 'release_status" = "404"' in publish["run"] assert 'release_status" != "200"' in publish["run"] assert 'tag_commit" != "$GITHUB_SHA' in publish["run"] - assert "immutable-releases" in publish["run"] - assert 'document.get("enabled") is not True' in publish["run"] + assert "scripts/verify_release_hosting.py" in publish["run"] assert '"$GH_CLI" release verify "$RELEASE_TAG"' in publish["run"] assert '"$GH_CLI" release verify-asset "$RELEASE_TAG" "$artifact"' in publish["run"] assert "semantic-release" not in publish["run"] @@ -326,6 +399,15 @@ def test_release_workflow_publishes_the_attested_bytes_to_both_destinations(): transfer = next( step for step in build_steps if step["name"] == "Transfer release artifacts" ) + build_run = next( + step["run"] + for step in build_steps + if step["name"] == "Build exact release artifacts" + ) + assert "python scripts/check_source_boundary.py --dist dist" in build_run + assert build_run.index("uv build --wheel --sdist") < build_run.index( + "python scripts/check_source_boundary.py --dist dist" + ) assert attest["with"]["subject-path"].splitlines() == [ "dist/*.whl", "dist/*.tar.gz", diff --git a/tests/test_release_platform_versions.py b/tests/test_release_platform_versions.py index 4eb44e1a7..f868841fd 100644 --- a/tests/test_release_platform_versions.py +++ b/tests/test_release_platform_versions.py @@ -82,6 +82,13 @@ def test_release_refuses_a_nonstable_launcher_version() -> None: MODULE.release_component_versions(_manifest(), "2.0.0rc1") -def test_release_refuses_a_launcher_candidate_outside_the_exact_selection() -> None: - with pytest.raises(ValueError, match="does not match the candidate"): - MODULE.release_component_versions(_manifest(), "2.0.1") +def test_release_allows_new_launcher_with_reviewed_published_defaults() -> None: + versions = MODULE.release_component_versions(_manifest(), "2.0.1") + + assert versions["launcher"] == "2.0.1" + assert versions["flow"] == "1.0.2" + + +def test_release_refuses_launcher_older_than_published_platform() -> None: + with pytest.raises(ValueError, match="must not be older"): + MODULE.release_component_versions(_manifest(), "1.9.9") diff --git a/tests/test_source_boundary.py b/tests/test_source_boundary.py index fcaaefc01..8b7b0b445 100644 --- a/tests/test_source_boundary.py +++ b/tests/test_source_boundary.py @@ -14,9 +14,12 @@ from __future__ import annotations +import io import json import subprocess import sys +import tarfile +import zipfile from pathlib import Path import pytest @@ -163,6 +166,89 @@ def test_a_clean_tree_passes(tmp_path: Path, policy: guard.SourcePolicy) -> None assert guard.scan(root, policy) == [] +def _built_artifacts(tmp_path: Path, member: str, content: bytes) -> Path: + dist = tmp_path / "dist" + dist.mkdir() + with zipfile.ZipFile(dist / "package.whl", mode="w") as archive: + archive.writestr(member, content) + info = tarfile.TarInfo(f"package-1.0.0/{member}") + info.size = len(content) + with tarfile.open(dist / "package.tar.gz", mode="w:gz") as archive: + archive.addfile(info, io.BytesIO(content)) + return dist + + +def test_clean_built_artifacts_pass(tmp_path: Path, policy: guard.SourcePolicy) -> None: + dist = _built_artifacts(tmp_path, "package/module.py", b"value = 1\n") + + assert guard.scan_built_artifacts(dist, policy) == [] + + +def test_built_artifact_path_uses_rendered_policy( + tmp_path: Path, policy: guard.SourcePolicy +) -> None: + prefix = policy.built_artifact_path_prefixes[0] + dist = _built_artifacts(tmp_path, f"{prefix}/case.json", b"{}\n") + + violations = guard.scan_built_artifacts(dist, policy) + + assert any("private artifact prefix" in violation for violation in violations) + + +def test_built_artifact_content_uses_rendered_policy( + tmp_path: Path, policy: guard.SourcePolicy +) -> None: + banner = policy.content_signatures[0].encode() + dist = _built_artifacts(tmp_path, "package/data.bin", banner) + + violations = guard.scan_built_artifacts(dist, policy) + + assert any("private-artifact banner" in violation for violation in violations) + + +def test_built_artifact_rejects_unsafe_member_path( + tmp_path: Path, policy: guard.SourcePolicy +) -> None: + dist = _built_artifacts(tmp_path, "../outside.py", b"pass\n") + + violations = guard.scan_built_artifacts(dist, policy) + + assert any("unsafe archive path" in violation for violation in violations) + + +def test_built_artifact_rejects_windows_drive_member_paths( + tmp_path: Path, policy: guard.SourcePolicy +) -> None: + dist = tmp_path / "dist" + dist.mkdir() + with zipfile.ZipFile(dist / "package.whl", mode="w") as archive: + archive.writestr("C:/outside.py", b"pass\n") + raw = b"pass\n" + info = tarfile.TarInfo("C:/outside.py") + info.size = len(raw) + with tarfile.open(dist / "package.tar.gz", mode="w:gz") as archive: + archive.addfile(info, io.BytesIO(raw)) + + violations = guard.scan_built_artifacts(dist, policy) + + assert sum("unsafe archive path" in violation for violation in violations) == 2 + + +def test_built_artifact_rejects_tar_symlink( + tmp_path: Path, policy: guard.SourcePolicy +) -> None: + dist = _built_artifacts(tmp_path, "package/module.py", b"pass\n") + with tarfile.open(dist / "package.tar.gz", mode="w:gz") as archive: + info = tarfile.TarInfo("package-1.0.0/link") + info.type = tarfile.SYMTYPE + info.linkname = "/etc/passwd" + archive.addfile(info) + + violations = guard.scan_built_artifacts(dist, policy) + + assert any("symlink or special entry" in violation for violation in violations) + + # -------------------------------------------------------------------------- # Fail closed: no rules means no run. # -------------------------------------------------------------------------- @@ -215,6 +301,14 @@ def test_missing_enforcement_block_fails_closed(tmp_path: Path) -> None: assert "enforcement" in completed.stderr +def test_missing_built_artifact_rules_fail_closed(tmp_path: Path) -> None: + document = json.loads(guard.POLICY_PATH.read_text(encoding="utf-8")) + del document["enforcement"]["built_artifacts"] + completed = _run(["--policy", str(_write_policy(tmp_path, document))]) + assert completed.returncode == 2 + assert "built_artifacts" in completed.stderr + + def test_unclassified_repository_fails_closed() -> None: completed = _run(["--repo", "repository-with-no-manifest-entry"]) assert completed.returncode == 2 From 8de9783cbd4f60914f715d5dda61832a3f2bd500 Mon Sep 17 00:00:00 2001 From: abrichr Date: Fri, 28 Aug 2026 17:54:23 -0400 Subject: [PATCH 10/10] ci: require the admitted Flow release --- .github/workflows/release-and-publish.yml | 95 ++++- scripts/prepare_flow_admission_candidate.py | 371 ++++++++++++++++++ .../test_prepare_flow_admission_candidate.py | 227 +++++++++++ tests/test_release_lock.py | 51 +++ 4 files changed, 740 insertions(+), 4 deletions(-) create mode 100644 scripts/prepare_flow_admission_candidate.py create mode 100644 tests/test_prepare_flow_admission_candidate.py diff --git a/.github/workflows/release-and-publish.yml b/.github/workflows/release-and-publish.yml index 51091511e..5808952d9 100644 --- a/.github/workflows/release-and-publish.yml +++ b/.github/workflows/release-and-publish.yml @@ -30,8 +30,85 @@ concurrency: cancel-in-progress: false jobs: + stage-flow-admission: + if: >- + (github.event_name == 'workflow_dispatch') || + (github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')) + runs-on: ubuntu-latest + permissions: + contents: read + outputs: + admission_reference_json: ${{ steps.candidate.outputs.admission_reference_json }} + artifact_inventory_json: ${{ steps.candidate.outputs.artifact_inventory_json }} + flow_source_commit: ${{ steps.candidate.outputs.flow_source_commit }} + flow_version: ${{ steps.candidate.outputs.flow_version }} + flow_tag: ${{ steps.candidate.outputs.flow_tag }} + central_registry_source_commit: ${{ steps.candidate.outputs.central_registry_source_commit }} + + steps: + - name: Checkout the exact launcher candidate + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.ref }} + persist-credentials: false + + - name: Set up Python + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.12" + + - name: Stage the exact selected Flow release and its current admission + id: candidate + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + project_version="$(python - <<'PY' + import tomllib + from pathlib import Path + + with Path("pyproject.toml").open("rb") as stream: + print(tomllib.load(stream)["project"]["version"]) + PY + )" + python scripts/prepare_flow_admission_candidate.py \ + --manifest platform-manifest.json \ + --launcher-version "$project_version" \ + --artifact-root flow-admission-candidate \ + --github-output "$GITHUB_OUTPUT" + + - name: Transfer the exact Flow admission candidate + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: flow-admission-candidate + path: flow-admission-candidate/ + if-no-files-found: error + retention-days: 1 + + verify-flow-admission: + needs: stage-flow-admission + permissions: + contents: read + attestations: read + id-token: write + uses: OpenAdaptAI/.github/.github/workflows/verify-production-release-admission.yml@c05ff5c0633e0a1f63d9af74eeb69c31dac3e9ba + with: + admission_reference_json: ${{ needs.stage-flow-admission.outputs.admission_reference_json }} + artifact_inventory_json: ${{ needs.stage-flow-admission.outputs.artifact_inventory_json }} + candidate_artifact_name: flow-admission-candidate + expected_target: flow + expected_repository: OpenAdaptAI/openadapt-flow + expected_repository_id: "1291376938" + expected_source_commit: ${{ needs.stage-flow-admission.outputs.flow_source_commit }} + expected_version: ${{ needs.stage-flow-admission.outputs.flow_version }} + expected_tag: ${{ needs.stage-flow-admission.outputs.flow_tag }} + central_verifier_sha: c05ff5c0633e0a1f63d9af74eeb69c31dac3e9ba + create-release-tag: - if: github.event_name == 'workflow_dispatch' + needs: verify-flow-admission + if: >- + github.event_name == 'workflow_dispatch' && + needs.verify-flow-admission.result == 'success' runs-on: ubuntu-latest environment: release-identity permissions: @@ -169,7 +246,11 @@ jobs: git push origin "refs/tags/$RELEASE_TAG" build-and-attest: - if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') + needs: verify-flow-admission + if: >- + github.event_name == 'push' && + startsWith(github.ref, 'refs/tags/v') && + needs.verify-flow-admission.result == 'success' runs-on: ubuntu-latest permissions: contents: read @@ -769,6 +850,8 @@ jobs: report-release-failure: needs: + - stage-flow-admission + - verify-flow-admission - create-release-tag - build-and-attest - preflight-github @@ -777,7 +860,9 @@ jobs: - verify-publication if: >- ${{ always() && - (needs.create-release-tag.result == 'failure' || + (needs.stage-flow-admission.result == 'failure' || + needs.verify-flow-admission.result == 'failure' || + needs.create-release-tag.result == 'failure' || needs.build-and-attest.result == 'failure' || needs.preflight-github.result == 'failure' || needs.publish-pypi.result == 'failure' || @@ -792,6 +877,8 @@ jobs: env: GH_TOKEN: ${{ github.token }} CREATE_TAG_RESULT: ${{ needs.create-release-tag.result }} + FLOW_STAGE_RESULT: ${{ needs.stage-flow-admission.result }} + FLOW_ADMISSION_RESULT: ${{ needs.verify-flow-admission.result }} BUILD_RESULT: ${{ needs.build-and-attest.result }} GITHUB_PREFLIGHT_RESULT: ${{ needs.preflight-github.result }} PYPI_RESULT: ${{ needs.publish-pypi.result }} @@ -802,7 +889,7 @@ jobs: TITLE="Release workflow failed" BODY="The release workflow failed: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} - create-tag=${CREATE_TAG_RESULT}, build-and-attest=${BUILD_RESULT}, preflight-github=${GITHUB_PREFLIGHT_RESULT}, publish-pypi=${PYPI_RESULT}, publish-github=${GITHUB_RESULT}, verify-publication=${VERIFY_RESULT}. + flow-stage=${FLOW_STAGE_RESULT}, flow-admission=${FLOW_ADMISSION_RESULT}, create-tag=${CREATE_TAG_RESULT}, build-and-attest=${BUILD_RESULT}, preflight-github=${GITHUB_PREFLIGHT_RESULT}, publish-pypi=${PYPI_RESULT}, publish-github=${GITHUB_RESULT}, verify-publication=${VERIFY_RESULT}. If the annotated tag exists, rerun only the failed jobs in this run with gh run rerun ${{ github.run_id }} --failed. Do not start a new full run or create a recovery tag." existing="$(gh issue list --repo "$GITHUB_REPOSITORY" --state open \ diff --git a/scripts/prepare_flow_admission_candidate.py b/scripts/prepare_flow_admission_candidate.py new file mode 100644 index 000000000..3627bad94 --- /dev/null +++ b/scripts/prepare_flow_admission_candidate.py @@ -0,0 +1,371 @@ +#!/usr/bin/env python3 +"""Stage the exact Flow package selected by one launcher release candidate.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import re +import urllib.parse +import urllib.request +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Callable + +from release_platform_versions import load_release_component_versions + +CENTRAL_API_REF = "https://api.github.com/repos/OpenAdaptAI/.github/git/ref/heads/main" +CENTRAL_RAW_ROOT = "https://raw.githubusercontent.com/OpenAdaptAI/.github" +CENTRAL_REPOSITORY = "OpenAdaptAI/.github" +CENTRAL_REPOSITORY_ID = "858454062" +CENTRAL_REPOSITORY_OWNER_ID = "132681217" +REFERENCE_SCHEMA = "openadapt.production-evidence-object-reference/v2" +REGISTRY_SCHEMA = "openadapt.production-evidence-registry/v2" +FLOW_REPOSITORY = "OpenAdaptAI/openadapt-flow" +FLOW_REPOSITORY_ID = "1291376938" +FLOW_CLAIM_SCOPE = "production_flow" +HEX40 = re.compile(r"^[0-9a-f]{40}$") +SHA256 = re.compile(r"^[0-9a-f]{64}$") +STABLE_VERSION = re.compile(r"^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$") +MAX_ARTIFACT_BYTES = 128 * 1024 * 1024 +ARTIFACT_PROFILES = { + "sdist": ("python-sdist", "application/gzip", ".tar.gz"), + "bdist_wheel": ("python-wheel", "application/zip", ".whl"), +} +REGISTRY_ENTRY_FIELDS = { + "registry_entry_sha256", + "kind", + "object_media_type", + "object_path", + "object_schema_version", + "object_sha256", + "semantic_identity_sha256", + "size_bytes", + "subject_sha256", +} + + +@dataclass(frozen=True) +class FlowArtifact: + name: str + kind: str + url: str + sha256: str + media_type: str + + +@dataclass(frozen=True) +class FlowCandidate: + version: str + tag: str + source_commit: str + artifacts: tuple[FlowArtifact, ...] + + +def _object(value: Any, context: str) -> dict[str, Any]: + if not isinstance(value, dict): + raise ValueError(f"{context} must be an object") + return value + + +def _load_manifest(path: Path) -> dict[str, Any]: + if path.is_symlink() or not path.is_file(): + raise ValueError("platform manifest path is missing or invalid") + try: + return _object( + json.loads(path.read_text(encoding="utf-8")), "platform manifest" + ) + except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc: + raise ValueError(f"cannot read the platform manifest: {exc}") from exc + + +def _artifact_url(value: Any, name: str) -> str: + if not isinstance(value, str): + raise ValueError(f"Flow artifact URL is invalid: {name}") + parsed = urllib.parse.urlsplit(value) + if ( + parsed.scheme != "https" + or parsed.hostname != "files.pythonhosted.org" + or parsed.port is not None + or parsed.username is not None + or parsed.password is not None + or parsed.query + or parsed.fragment + or Path(urllib.parse.unquote(parsed.path)).name != name + ): + raise ValueError( + f"Flow artifact URL is outside the exact PyPI file boundary: {name}" + ) + return value + + +def load_flow_candidate(manifest_path: Path, launcher_version: str) -> FlowCandidate: + """Return the exact published Flow candidate selected by the manifest.""" + + document = _load_manifest(manifest_path) + versions = load_release_component_versions(manifest_path, launcher_version) + flow = _object( + _object(document.get("components"), "platform components").get("flow"), + "Flow component", + ) + version = versions["flow"] + if STABLE_VERSION.fullmatch(version) is None or flow.get("version") != version: + raise ValueError("Flow version is not one exact stable selected version") + if flow.get("package") != "openadapt-flow" or flow.get("source") != "pypi": + raise ValueError("Flow component is not the canonical published package") + + provenance = _object(flow.get("provenance"), "Flow provenance") + tag = f"v{version}" + source_commit = provenance.get("commit") + if ( + provenance.get("repository") != FLOW_REPOSITORY + or provenance.get("release_ref") != tag + or not isinstance(source_commit, str) + or HEX40.fullmatch(source_commit) is None + ): + raise ValueError( + "Flow provenance does not bind the exact repository, tag, and commit" + ) + + artifact_values = flow.get("artifacts") + if not isinstance(artifact_values, list) or len(artifact_values) != 2: + raise ValueError("Flow must have exactly one wheel and one source distribution") + artifacts: list[FlowArtifact] = [] + observed_types: set[str] = set() + for index, raw in enumerate(artifact_values): + artifact = _object(raw, f"Flow artifact {index}") + if set(artifact) != {"type", "filename", "url", "sha256"}: + raise ValueError( + f"Flow artifact {index} fields differ from the published manifest contract" + ) + artifact_type = artifact.get("type") + if artifact_type not in ARTIFACT_PROFILES or artifact_type in observed_types: + raise ValueError( + "Flow artifact types must be one wheel and one source distribution" + ) + observed_types.add(artifact_type) + kind, media_type, suffix = ARTIFACT_PROFILES[artifact_type] + name = artifact.get("filename") + digest = artifact.get("sha256") + expected_name = ( + f"openadapt_flow-{version}.tar.gz" + if artifact_type == "sdist" + else f"openadapt_flow-{version}-py3-none-any.whl" + ) + if ( + not isinstance(name, str) + or name != Path(name).name + or name != expected_name + or not name.endswith(suffix) + ): + raise ValueError(f"Flow artifact filename is invalid: {name}") + if not isinstance(digest, str) or SHA256.fullmatch(digest) is None: + raise ValueError(f"Flow artifact digest is invalid: {name}") + artifacts.append( + FlowArtifact( + name=name, + kind=kind, + url=_artifact_url(artifact.get("url"), name), + sha256=digest, + media_type=media_type, + ) + ) + if observed_types != set(ARTIFACT_PROFILES): + raise ValueError( + "Flow artifact types must be one wheel and one source distribution" + ) + artifacts.sort(key=lambda item: (item.kind, item.name, item.sha256)) + return FlowCandidate( + version=version, + tag=tag, + source_commit=source_commit, + artifacts=tuple(artifacts), + ) + + +def _download(url: str, token: str | None = None) -> bytes: + headers = {"User-Agent": "OpenAdapt-launcher-release/1"} + if token: + headers["Authorization"] = f"Bearer {token}" + request = urllib.request.Request(url, headers=headers) + with urllib.request.urlopen(request, timeout=30) as response: # noqa: S310 + final = urllib.parse.urlsplit(response.geturl()) + requested = urllib.parse.urlsplit(url) + if final.scheme != "https" or final.hostname != requested.hostname: + raise ValueError("download redirected outside its exact host") + raw = response.read(MAX_ARTIFACT_BYTES + 1) + if not raw or len(raw) > MAX_ARTIFACT_BYTES: + raise ValueError("download is empty or exceeds the artifact size limit") + return raw + + +def stage_artifacts( + candidate: FlowCandidate, + artifact_root: Path, + *, + fetch_bytes: Callable[[str], bytes] = _download, +) -> dict[str, Any]: + """Write exact candidate bytes and return the central artifact inventory.""" + + if artifact_root.is_symlink(): + raise ValueError("artifact root cannot be a symlink") + artifact_root.mkdir(parents=True, exist_ok=True) + if not artifact_root.is_dir() or any(artifact_root.iterdir()): + raise ValueError("artifact root must be an empty directory") + + inventory_artifacts: list[dict[str, Any]] = [] + for artifact in candidate.artifacts: + raw = fetch_bytes(artifact.url) + actual = hashlib.sha256(raw).hexdigest() + if actual != artifact.sha256: + raise ValueError( + f"Flow artifact bytes differ from the manifest: {artifact.name}" + ) + target = artifact_root / artifact.name + with target.open("xb") as handle: + handle.write(raw) + inventory_artifacts.append( + { + "name": artifact.name, + "kind": artifact.kind, + "sha256": f"sha256:{actual}", + "size_bytes": len(raw), + "media_type": artifact.media_type, + "publish_destinations": ["github-release", "pypi"], + } + ) + return { + "schema_version": "openadapt.production-release-artifact-inventory/v1", + "target": "flow", + "claim_scope": FLOW_CLAIM_SCOPE, + "artifacts": inventory_artifacts, + } + + +def select_release_admission_reference( + registry: dict[str, Any], source_commit: str +) -> dict[str, Any]: + """Select the newest registered release admission from protected central main.""" + + required = { + "$schema", + "schema_version", + "repository", + "repository_id", + "repository_owner_id", + "revision", + "previous_registry_head_sha256", + "registry_head_sha256", + "signer_registry", + "signer_registry_history", + "entries", + } + if set(registry) != required: + raise ValueError("central evidence registry fields differ") + revision = registry.get("revision") + head = registry.get("registry_head_sha256") + entries = registry.get("entries") + if ( + registry.get("schema_version") != REGISTRY_SCHEMA + or registry.get("repository") != CENTRAL_REPOSITORY + or registry.get("repository_id") != CENTRAL_REPOSITORY_ID + or registry.get("repository_owner_id") != CENTRAL_REPOSITORY_OWNER_ID + or not isinstance(revision, int) + or isinstance(revision, bool) + or revision < 1 + or not isinstance(head, str) + or re.fullmatch(r"sha256:[0-9a-f]{64}", head) is None + or not isinstance(entries, list) + or HEX40.fullmatch(source_commit) is None + ): + raise ValueError("central evidence registry identity is invalid") + matches = [ + entry + for entry in entries + if isinstance(entry, dict) and entry.get("kind") == "qualification-release" + ] + if not matches: + raise ValueError( + "central protected main has no registered Flow release admission" + ) + entry = matches[-1] + if set(entry) != REGISTRY_ENTRY_FIELDS: + raise ValueError("central release admission registry entry fields differ") + return { + "schema_version": REFERENCE_SCHEMA, + "repository": CENTRAL_REPOSITORY, + "repository_id": CENTRAL_REPOSITORY_ID, + "repository_owner_id": CENTRAL_REPOSITORY_OWNER_ID, + "registry_source_commit": source_commit, + "registry_revision": revision, + "registry_head_sha256": head, + **entry, + } + + +def load_current_admission_reference(token: str) -> tuple[str, dict[str, Any]]: + ref_raw = _download(CENTRAL_API_REF, token) + try: + commit = _object(json.loads(ref_raw), "central main response")["object"]["sha"] + except (json.JSONDecodeError, KeyError, TypeError) as exc: + raise ValueError("central main response is invalid") from exc + if not isinstance(commit, str) or HEX40.fullmatch(commit) is None: + raise ValueError("central main response does not contain one exact commit") + registry_raw = _download(f"{CENTRAL_RAW_ROOT}/{commit}/evidence-registry.json") + try: + registry = _object(json.loads(registry_raw), "central evidence registry") + except json.JSONDecodeError as exc: + raise ValueError("central evidence registry is not JSON") from exc + return commit, select_release_admission_reference(registry, commit) + + +def _write_github_outputs(path: Path, outputs: dict[str, str]) -> None: + with path.open("a", encoding="utf-8") as handle: + for key, value in outputs.items(): + if "\n" in value or "\r" in value: + raise ValueError(f"GitHub output contains a line break: {key}") + handle.write(f"{key}={value}\n") + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--manifest", type=Path, required=True) + parser.add_argument("--launcher-version", required=True) + parser.add_argument("--artifact-root", type=Path, required=True) + parser.add_argument("--github-output", type=Path, required=True) + args = parser.parse_args() + token = os.environ.get("GH_TOKEN", "") + if not token: + parser.exit(1, "GH_TOKEN is required\n") + try: + candidate = load_flow_candidate(args.manifest, args.launcher_version) + inventory = stage_artifacts(candidate, args.artifact_root) + registry_commit, reference = load_current_admission_reference(token) + _write_github_outputs( + args.github_output, + { + "admission_reference_json": json.dumps( + reference, sort_keys=True, separators=(",", ":") + ), + "artifact_inventory_json": json.dumps( + inventory, sort_keys=True, separators=(",", ":") + ), + "flow_source_commit": candidate.source_commit, + "flow_version": candidate.version, + "flow_tag": candidate.tag, + "central_registry_source_commit": registry_commit, + }, + ) + except (OSError, ValueError) as exc: + parser.exit(1, f"{exc}\n") + print( + "OK: staged the exact selected Flow artifacts and current admission " + f"reference for {candidate.tag}." + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_prepare_flow_admission_candidate.py b/tests/test_prepare_flow_admission_candidate.py new file mode 100644 index 000000000..d3d1cb10b --- /dev/null +++ b/tests/test_prepare_flow_admission_candidate.py @@ -0,0 +1,227 @@ +from __future__ import annotations + +import hashlib +import importlib.util +import json +import sys +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[1] +SCRIPT = ROOT / "scripts" / "prepare_flow_admission_candidate.py" +sys.path.insert(0, str(ROOT / "scripts")) +from release_platform_versions import COMPONENT_PACKAGES # noqa: E402 + +SPEC = importlib.util.spec_from_file_location( + "prepare_flow_admission_candidate", SCRIPT +) +assert SPEC and SPEC.loader +MODULE = importlib.util.module_from_spec(SPEC) +sys.modules[SPEC.name] = MODULE +SPEC.loader.exec_module(MODULE) + + +def _manifest(tmp_path: Path, payloads: dict[str, bytes]) -> Path: + components = { + role: {"package": package, "version": f"1.0.{index}"} + for index, (role, package) in enumerate( + COMPONENT_PACKAGES.items(), + start=1, + ) + } + components["launcher"]["version"] = "2.0.0" + components["flow"] = { + "package": "openadapt-flow", + "version": "1.35.0", + "source": "pypi", + "provenance": { + "repository": "OpenAdaptAI/openadapt-flow", + "release_ref": "v1.35.0", + "commit": "e" * 40, + }, + "artifacts": [ + { + "type": "bdist_wheel", + "filename": name, + "url": f"https://files.pythonhosted.org/packages/aa/{name}", + "sha256": hashlib.sha256(raw).hexdigest(), + } + for name, raw in payloads.items() + if name.endswith(".whl") + ] + + [ + { + "type": "sdist", + "filename": name, + "url": f"https://files.pythonhosted.org/packages/bb/{name}", + "sha256": hashlib.sha256(raw).hexdigest(), + } + for name, raw in payloads.items() + if name.endswith(".tar.gz") + ], + } + document = { + "manifest_kind": "openadapt-platform-release-manifest", + "schema_version": "2.0.0", + "components": components, + "release_selection": { + "mode": "explicit-published", + "component_versions": { + role: component["version"] for role, component in components.items() + }, + }, + } + path = tmp_path / "platform-manifest.json" + path.write_text(json.dumps(document), encoding="utf-8") + return path + + +def _payloads() -> dict[str, bytes]: + return { + "openadapt_flow-1.35.0-py3-none-any.whl": b"wheel-bytes", + "openadapt_flow-1.35.0.tar.gz": b"sdist-bytes", + } + + +def _registry(entry: dict | None = None) -> dict: + return { + "$schema": "schemas/evidence-registry.schema.json", + "schema_version": "openadapt.production-evidence-registry/v2", + "repository": "OpenAdaptAI/.github", + "repository_id": "858454062", + "repository_owner_id": "132681217", + "revision": 4, + "previous_registry_head_sha256": "sha256:" + "a" * 64, + "registry_head_sha256": "sha256:" + "b" * 64, + "signer_registry": {}, + "signer_registry_history": [], + "entries": [] if entry is None else [entry], + } + + +def _admission_entry() -> dict: + return { + "registry_entry_sha256": "sha256:" + "1" * 64, + "kind": "qualification-release", + "object_schema_version": "openadapt.qualification-release/v2", + "object_media_type": "application/vnd.openadapt.qualification-release+json;version=2", + "object_path": "production-evidence/objects/sha256/22/" + + "2" * 64 + + ".qualification-release.json", + "object_sha256": "sha256:" + "2" * 64, + "semantic_identity_sha256": "sha256:" + "3" * 64, + "size_bytes": 1200, + "subject_sha256": None, + } + + +def test_stages_exact_flow_bytes_and_closed_inventory(tmp_path: Path) -> None: + payloads = _payloads() + candidate = MODULE.load_flow_candidate(_manifest(tmp_path, payloads), "2.0.0") + by_url = {artifact.url: payloads[artifact.name] for artifact in candidate.artifacts} + root = tmp_path / "candidate" + + inventory = MODULE.stage_artifacts( + candidate, root, fetch_bytes=lambda url: by_url[url] + ) + + assert candidate.version == "1.35.0" + assert candidate.tag == "v1.35.0" + assert candidate.source_commit == "e" * 40 + assert sorted(path.name for path in root.iterdir()) == sorted(payloads) + assert inventory["target"] == "flow" + assert inventory["claim_scope"] == "production_flow" + assert [item["kind"] for item in inventory["artifacts"]] == [ + "python-sdist", + "python-wheel", + ] + assert all( + item["publish_destinations"] == ["github-release", "pypi"] + for item in inventory["artifacts"] + ) + + +def test_refuses_dynamic_platform_selection(tmp_path: Path) -> None: + manifest = _manifest(tmp_path, _payloads()) + document = json.loads(manifest.read_text(encoding="utf-8")) + document["release_selection"] = { + "mode": "latest-published", + "component_versions": {}, + } + manifest.write_text(json.dumps(document), encoding="utf-8") + + with pytest.raises(ValueError, match="explicit-published"): + MODULE.load_flow_candidate(manifest, "2.0.0") + + +def test_refuses_a_flow_artifact_outside_files_pythonhosted(tmp_path: Path) -> None: + manifest = _manifest(tmp_path, _payloads()) + document = json.loads(manifest.read_text(encoding="utf-8")) + document["components"]["flow"]["artifacts"][0]["url"] = ( + "https://example.com/openadapt_flow-1.35.0-py3-none-any.whl" + ) + manifest.write_text(json.dumps(document), encoding="utf-8") + + with pytest.raises(ValueError, match="outside the exact PyPI file boundary"): + MODULE.load_flow_candidate(manifest, "2.0.0") + + +def test_refuses_downloaded_bytes_that_differ_from_the_manifest(tmp_path: Path) -> None: + candidate = MODULE.load_flow_candidate(_manifest(tmp_path, _payloads()), "2.0.0") + + with pytest.raises(ValueError, match="bytes differ"): + MODULE.stage_artifacts( + candidate, + tmp_path / "candidate", + fetch_bytes=lambda _url: b"different", + ) + + +def test_selects_the_last_registered_release_admission() -> None: + older = _admission_entry() + older["object_sha256"] = "sha256:" + "4" * 64 + latest = _admission_entry() + registry = _registry() + registry["entries"] = [older, latest] + + reference = MODULE.select_release_admission_reference(registry, "c" * 40) + + assert reference["schema_version"] == ( + "openadapt.production-evidence-object-reference/v2" + ) + assert reference["registry_source_commit"] == "c" * 40 + assert reference["registry_revision"] == 4 + assert reference["object_sha256"] == latest["object_sha256"] + + +def test_refuses_a_registry_without_a_release_admission() -> None: + with pytest.raises(ValueError, match="no registered Flow release admission"): + MODULE.select_release_admission_reference(_registry(), "c" * 40) + + +def test_refuses_extra_release_admission_entry_fields() -> None: + entry = _admission_entry() + entry["registry_source_commit"] = "d" * 40 + + with pytest.raises(ValueError, match="entry fields differ"): + MODULE.select_release_admission_reference(_registry(entry), "c" * 40) + + +def test_github_outputs_are_single_line_canonical_json(tmp_path: Path) -> None: + output = tmp_path / "github-output" + MODULE._write_github_outputs( + output, + { + "document": json.dumps( + {"b": 2, "a": 1}, sort_keys=True, separators=(",", ":") + ) + }, + ) + + assert output.read_text(encoding="utf-8") == 'document={"a":1,"b":2}\n' + + +def test_refuses_multiline_github_output(tmp_path: Path) -> None: + with pytest.raises(ValueError, match="line break"): + MODULE._write_github_outputs(tmp_path / "output", {"bad": "one\ntwo"}) diff --git a/tests/test_release_lock.py b/tests/test_release_lock.py index e7003b1fe..ece63df47 100644 --- a/tests/test_release_lock.py +++ b/tests/test_release_lock.py @@ -101,6 +101,12 @@ def test_release_workflow_pins_actions_and_separates_permissions(): "cancel-in-progress": False, } jobs = document["jobs"] + assert jobs["stage-flow-admission"]["permissions"] == {"contents": "read"} + assert jobs["verify-flow-admission"]["permissions"] == { + "contents": "read", + "attestations": "read", + "id-token": "write", + } assert jobs["create-release-tag"]["permissions"] == {"contents": "read"} assert jobs["build-and-attest"]["permissions"] == { "contents": "read", @@ -248,6 +254,51 @@ def test_release_workflow_checks_the_platform_selection_before_any_release_write assert build["steps"].index(guard) < build["steps"].index(artifact_build) +def test_release_workflow_requires_the_exact_flow_admission_before_tag_or_publish(): + workflow_path = ROOT / ".github/workflows/release-and-publish.yml" + document = yaml.safe_load(workflow_path.read_text(encoding="utf-8")) + workflow = workflow_path.read_text(encoding="utf-8") + jobs = document["jobs"] + + stage = jobs["stage-flow-admission"] + candidate = next(step for step in stage["steps"] if step.get("id") == "candidate") + transfer = next( + step + for step in stage["steps"] + if step["name"] == "Transfer the exact Flow admission candidate" + ) + assert "scripts/prepare_flow_admission_candidate.py" in candidate["run"] + assert "--manifest platform-manifest.json" in candidate["run"] + assert candidate["env"]["GH_TOKEN"] == "${{ github.token }}" + assert transfer["with"]["name"] == "flow-admission-candidate" + assert transfer["with"]["retention-days"] == 1 + + verify = jobs["verify-flow-admission"] + central = "c05ff5c0633e0a1f63d9af74eeb69c31dac3e9ba" + assert verify["uses"] == ( + "OpenAdaptAI/.github/.github/workflows/" + f"verify-production-release-admission.yml@{central}" + ) + assert verify["with"]["central_verifier_sha"] == central + assert verify["with"]["expected_target"] == "flow" + assert verify["with"]["expected_repository"] == "OpenAdaptAI/openadapt-flow" + assert verify["with"]["expected_repository_id"] == "1291376938" + assert verify["with"]["candidate_artifact_name"] == (transfer["with"]["name"]) + + create = jobs["create-release-tag"] + build = jobs["build-and-attest"] + assert create["needs"] == "verify-flow-admission" + assert "needs.verify-flow-admission.result == 'success'" in create["if"] + assert build["needs"] == "verify-flow-admission" + assert "needs.verify-flow-admission.result == 'success'" in build["if"] + assert workflow.index("verify-flow-admission:") < workflow.index( + "Create and push only the annotated release tag" + ) + report = jobs["report-release-failure"] + assert "needs.stage-flow-admission.result == 'failure'" in report["if"] + assert "needs.verify-flow-admission.result == 'failure'" in report["if"] + + def test_release_workflow_publishes_from_the_exact_app_tag_with_oidc(): workflow_path = ROOT / ".github/workflows/release-and-publish.yml" document = yaml.safe_load(workflow_path.read_text(encoding="utf-8"))