From c4d6d482a4c14005e4e8c3916dbef07ce7014b83 Mon Sep 17 00:00:00 2001 From: abrichr Date: Mon, 27 Jul 2026 19:02:55 -0400 Subject: [PATCH] chore(ci): pin every workflow action to a full commit SHA release.yml and ci.yml were the outliers in this org: codeql.yml, dependency-review.yml and secret-scan.yml in this same repo were already SHA-pinned, as are openadapt-ops and openadapt-tray. The one that mattered was `pypa/gh-action-pypi-publish@release/v1` on the PyPI publish job. `release/v1` is a *branch*, not a tag, so whoever can move that branch decides what code runs with this repo's PyPI Trusted Publishing OIDC identity (`id-token: write`, `environment: pypi`) at a moment nobody is watching. The remaining eleven `uses:` lines floated on major tags, which are mutable for the same reason. Pinning only -- no action was upgraded. Each SHA was resolved from the upstream repo's own tag refs (`git ls-remote --tags`, all lightweight, so each ref is already the commit) and re-confirmed as a commit object via the GitHub API, then checked to be exactly what the floating ref resolves to today: actions/checkout@v7 -> 3d3c42e5 (v7.0.1; v7 == v7.0.1) actions/setup-python@v7 -> 5fda3b95 (v7.0.0; v7 == v7.0.0) actions/setup-node@v7 -> 820762786 (v7.0.0; v7 == v7.0.0) actions/upload-artifact@v7 -> 043fb46d (v7.0.1; v7 == v7.0.1) actions/download-artifact@v8 -> 3e5f45b2 (v8.0.1; v8 == v8.0.1) pypa/gh-action-pypi-publish -> ba38be9e (v1.14.1) For the publish action, `release/v1` currently points at the same commit as the v1.14.1 tag, so pinning to the tag's commit is byte-identical to what runs today -- it just stops moving underneath us. `test_release_actions_are_pinned_to_commits` is ported from openadapt-tray's test of the same name so this cannot regress; it lives in tests/test_distribution.py, which the release workflow already runs as its own version-consistency gate before any publish step. A companion test covers the other workflows and requires the trailing `# vX.Y.Z` comment so the human-readable version is never lost to the SHA. Both fail against the pre-change workflows (12 floating refs) and pass after. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01NyCHrzA1psrKMFfroYbzaM --- .github/workflows/ci.yml | 4 ++-- .github/workflows/release.yml | 20 ++++++++-------- tests/test_distribution.py | 43 +++++++++++++++++++++++++++++++++++ 3 files changed, 55 insertions(+), 12 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index df583f1..3b30329 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,8 +13,8 @@ jobs: matrix: python-version: ["3.10", "3.11", "3.12"] steps: - - uses: actions/checkout@v7 - - uses: actions/setup-python@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: ${{ matrix.python-version }} cache: pip diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f85a847..9f8ac54 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -42,11 +42,11 @@ jobs: name: Build + validate (dry run, no publish) runs-on: ubuntu-latest steps: - - uses: actions/checkout@v7 - - uses: actions/setup-python@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: "3.12" - - uses: actions/setup-node@v7 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: "20" - name: Install tooling @@ -91,13 +91,13 @@ jobs: sys.exit(1 if errors else 0) PY - name: Upload built distributions - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: dist path: dist/ if-no-files-found: error - name: Upload local MCPB - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: mcpb path: mcpb-dist/ @@ -113,7 +113,7 @@ jobs: id-token: write # OIDC for PyPI Trusted Publishing contents: read steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Resolve release version + assert it matches the package id: ver shell: bash @@ -134,7 +134,7 @@ jobs: exit 1 fi - name: Download built distributions - uses: actions/download-artifact@v8 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: dist path: dist/ @@ -151,13 +151,13 @@ jobs: fi - name: Publish to PyPI (OIDC Trusted Publishing) if: ${{ vars.PYPI_PUBLISH_METHOD != 'token' }} - uses: pypa/gh-action-pypi-publish@release/v1 + uses: pypa/gh-action-pypi-publish@ba38be9e461d3875417946c167d0b5f3d385a247 # v1.14.1 with: # No password -> the action uses OIDC Trusted Publishing. print-hash: true - name: Publish to PyPI (API token fallback) if: ${{ vars.PYPI_PUBLISH_METHOD == 'token' }} - uses: pypa/gh-action-pypi-publish@release/v1 + uses: pypa/gh-action-pypi-publish@ba38be9e461d3875417946c167d0b5f3d385a247 # v1.14.1 with: password: ${{ secrets.PYPI_API_TOKEN }} print-hash: true @@ -171,7 +171,7 @@ jobs: id-token: write # OIDC for `mcp-publisher login github-oidc` contents: read steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Resolve release version id: ver shell: bash diff --git a/tests/test_distribution.py b/tests/test_distribution.py index 24acdc7..07961e0 100644 --- a/tests/test_distribution.py +++ b/tests/test_distribution.py @@ -23,6 +23,10 @@ PYPROJECT = REPO_ROOT / "pyproject.toml" README = REPO_ROOT / "README.md" RELEASE_WORKFLOW = REPO_ROOT / ".github" / "workflows" / "release.yml" +WORKFLOWS_DIR = REPO_ROOT / ".github" / "workflows" + +# `uses: owner/repo@` with the trailing ` # vX.Y.Z` comment stripped. +USES_REVISION = re.compile(r"(?m)^\s*(?:-\s+)?uses:\s+(\S+)@([^\s#]+)") REVERSE_DNS_NAME = "io.github.OpenAdaptAI/openadapt-agent" PYPI_NAME = "openadapt-agent" @@ -82,6 +86,45 @@ def test_release_workflow_runs_the_complete_archive_boundary() -> None: assert "python scripts/check_dist.py dist/*" in workflow +def test_release_actions_are_pinned_to_commits() -> None: + """Every action on the publish path must be a full 40-char commit SHA. + + A floating ref (``@v7``, or worse ``@release/v1``, which is a *branch*) + means whoever controls that ref decides what runs with this repo's PyPI + Trusted Publishing OIDC identity, at a moment nobody is watching. Ported + from openadapt-tray's test of the same name. + """ + workflow = RELEASE_WORKFLOW.read_text(encoding="utf-8") + revisions = [m.group(2) for m in USES_REVISION.finditer(workflow)] + assert revisions + unpinned = [rev for rev in revisions if not re.fullmatch(r"[0-9a-f]{40}", rev)] + assert not unpinned, f"release.yml uses unpinned action revisions: {unpinned}" + + +def test_every_workflow_action_is_pinned_to_a_commit() -> None: + """The same rule for the rest of the repo, so release.yml stays the norm. + + Each pin also carries a trailing ``# vX.Y.Z`` comment so the human-readable + version is never lost to the SHA. + """ + unpinned: list[str] = [] + uncommented: list[str] = [] + for workflow_file in sorted(WORKFLOWS_DIR.glob("*.yml")): + text = workflow_file.read_text(encoding="utf-8") + for line in text.splitlines(): + match = USES_REVISION.match(line) + if not match: + continue + action, revision = match.group(1), match.group(2) + where = f"{workflow_file.name}: {action}@{revision}" + if not re.fullmatch(r"[0-9a-f]{40}", revision): + unpinned.append(where) + elif not re.search(r"#\s*\S", line.split(revision, 1)[1]): + uncommented.append(where) + assert not unpinned, f"unpinned action revisions: {unpinned}" + assert not uncommented, f"pinned actions missing a version comment: {uncommented}" + + def test_serve_is_the_subcommand_and_bundles_is_required() -> None: args = _server_json()["packages"][0]["packageArguments"] positional = [a for a in args if a["type"] == "positional"]