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"]