From 28958bbfd522899fe7460f68c5738eddf07d4e10 Mon Sep 17 00:00:00 2001 From: hushen <190065939+918154429@users.noreply.github.com> Date: Sat, 19 Sep 2026 01:42:47 +0800 Subject: [PATCH] fix(ci): verify pinned action version comments --- .github/workflows/ci.yml | 34 +++++---- .github/workflows/codeql.yml | 6 +- .github/workflows/docs.yml | 6 +- .github/workflows/e2e.yml | 10 +-- .github/workflows/publish-mcp.yml | 2 +- .github/workflows/release-rehearsal.yml | 8 +- .github/workflows/release.yml | 26 +++---- .github/workflows/scorecard.yml | 2 +- .github/workflows/secret-scan.yml | 4 +- CONTRIBUTING.md | 13 ++++ scripts/action-pin-comments.py | 83 +++++++++++++++++++++ scripts/ci-local.sh | 1 + scripts/verify-action-pins.sh | 89 +++++++++++++++++++++++ tests/release/action-pin-comments.test.sh | 76 +++++++++++++++++++ tests/release/ci-local.test.sh | 17 +++++ 15 files changed, 332 insertions(+), 45 deletions(-) create mode 100644 scripts/action-pin-comments.py create mode 100755 scripts/verify-action-pins.sh create mode 100755 tests/release/action-pin-comments.test.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 23dce4d4..bdedb74b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,10 +20,10 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Set up Node - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: "24" @@ -44,6 +44,14 @@ jobs: python -m pip install --upgrade pip pip install yamllint==1.38.0 + - name: Test action pin comment verification + run: bash tests/release/action-pin-comments.test.sh + + - name: Verify action pin version comments + env: + GH_TOKEN: ${{ github.token }} + run: bash scripts/verify-action-pins.sh + - name: Check required repository files run: bash scripts/check_repo_completeness.sh @@ -210,10 +218,10 @@ jobs: working-directory: apps/sysknife-shell steps: - name: Checkout - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Set up Node - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: "24" @@ -240,7 +248,7 @@ jobs: CARGO_INCREMENTAL: 0 steps: - name: Checkout - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Install Tauri system dependencies run: | @@ -253,7 +261,7 @@ jobs: patchelf - name: Set up Rust - uses: dtolnay/rust-toolchain@4cda84d5c5c54efe2404f9d843567869ab1699d4 # stable + uses: dtolnay/rust-toolchain@4cda84d5c5c54efe2404f9d843567869ab1699d4 # stable (branch) with: components: rustfmt, clippy @@ -276,7 +284,7 @@ jobs: run: cargo doc --no-deps --workspace --locked - name: Install cargo-nextest - uses: taiki-e/install-action@e67fa11c4b9316fa714ddf0abed07a0c3143b95b # v2 + uses: taiki-e/install-action@e67fa11c4b9316fa714ddf0abed07a0c3143b95b # v2.87.4 with: tool: cargo-nextest @@ -308,10 +316,10 @@ jobs: SYSKNIFE_REQUIRE_POSTGRES: "1" steps: - name: Checkout - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Set up Rust - uses: dtolnay/rust-toolchain@4cda84d5c5c54efe2404f9d843567869ab1699d4 # stable + uses: dtolnay/rust-toolchain@4cda84d5c5c54efe2404f9d843567869ab1699d4 # stable (branch) - name: Cache Rust dependencies uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 @@ -328,10 +336,10 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Install cargo-audit - uses: taiki-e/install-action@e67fa11c4b9316fa714ddf0abed07a0c3143b95b # v2 + uses: taiki-e/install-action@e67fa11c4b9316fa714ddf0abed07a0c3143b95b # v2.87.4 with: tool: cargo-audit @@ -356,7 +364,7 @@ jobs: contents: read steps: - name: Checkout - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Review dependency changes # dependency-review requires GitHub Advanced Security + the dependency @@ -366,6 +374,6 @@ jobs: # (job stays green) while private and runs for real once public. The # job itself always runs, so this required check always reports a status. if: github.event.repository.visibility == 'public' - uses: actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294 # v4 + uses: actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294 # v5.0.0 with: fail-on-severity: high diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 73982687..48c91c52 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -35,7 +35,7 @@ jobs: build-mode: none steps: - name: Checkout - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 # CodeQL code scanning requires GitHub Advanced Security, which is free on # public repositories but a paid add-on on private ones. The repo is @@ -45,13 +45,13 @@ jobs: # check always reports a status. - name: Initialize CodeQL if: github.event.repository.visibility == 'public' - uses: github/codeql-action/init@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4 + uses: github/codeql-action/init@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9 with: languages: ${{ matrix.language }} build-mode: ${{ matrix.build-mode }} - name: Perform CodeQL analysis if: github.event.repository.visibility == 'public' - uses: github/codeql-action/analyze@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4 + uses: github/codeql-action/analyze@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9 with: category: "/language:${{ matrix.language }}" diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index c6f42955..0693012f 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -35,7 +35,7 @@ jobs: if: github.event.repository.visibility == 'public' runs-on: ubuntu-latest steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Install mdBook and plugins env: @@ -64,7 +64,7 @@ jobs: mdbook build - name: Upload Pages artifact - uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5 + uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0 with: path: book @@ -78,4 +78,4 @@ jobs: steps: - name: Deploy to GitHub Pages id: deployment - uses: actions/deploy-pages@368f82528645a54fb793d4d04e342629a3f51346 # v5 + uses: actions/deploy-pages@368f82528645a54fb793d4d04e342629a3f51346 # v5.0.1 diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 72217af8..6cf66902 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -67,7 +67,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Install ShellCheck run: | @@ -81,7 +81,7 @@ jobs: | xargs -0 shellcheck --severity=warning - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@4cda84d5c5c54efe2404f9d843567869ab1699d4 # stable + uses: dtolnay/rust-toolchain@4cda84d5c5c54efe2404f9d843567869ab1699d4 # stable (branch) - name: Cache Rust dependencies uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 @@ -99,7 +99,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Install system dependencies run: | @@ -110,7 +110,7 @@ jobs: libayatana-appindicator3-dev librsvg2-dev - name: Set up Rust - uses: dtolnay/rust-toolchain@4cda84d5c5c54efe2404f9d843567869ab1699d4 # stable + uses: dtolnay/rust-toolchain@4cda84d5c5c54efe2404f9d843567869ab1699d4 # stable (branch) - name: Cache Rust dependencies uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 @@ -198,7 +198,7 @@ jobs: - name: Upload story logs if: always() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: e2e-story-logs path: tests/e2e/logs/ diff --git a/.github/workflows/publish-mcp.yml b/.github/workflows/publish-mcp.yml index 83b6fcdb..191dcc2b 100644 --- a/.github/workflows/publish-mcp.yml +++ b/.github/workflows/publish-mcp.yml @@ -54,7 +54,7 @@ jobs: contents: read steps: - name: Checkout - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ inputs.ref || github.ref }} # Full history so the reachability guard below can check ancestry. diff --git a/.github/workflows/release-rehearsal.yml b/.github/workflows/release-rehearsal.yml index 8e329747..a66bb5b7 100644 --- a/.github/workflows/release-rehearsal.yml +++ b/.github/workflows/release-rehearsal.yml @@ -24,16 +24,16 @@ jobs: runs-on: ${{ matrix.runner }} steps: - name: Checkout - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Set up Rust - uses: dtolnay/rust-toolchain@4cda84d5c5c54efe2404f9d843567869ab1699d4 # stable + uses: dtolnay/rust-toolchain@4cda84d5c5c54efe2404f9d843567869ab1699d4 # stable (branch) - name: Cache Rust dependencies uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 - name: Set up Node - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: "24" package-manager-cache: false @@ -47,7 +47,7 @@ jobs: run: scripts/release_rehearsal.sh --full --output dist - name: Upload rehearsal artifacts - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: release-rehearsal-${{ matrix.arch }} path: dist/ diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d23171e6..8683ea88 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -33,10 +33,10 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Set up Node.js - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: '24' @@ -78,7 +78,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Install system dependencies run: | @@ -86,7 +86,7 @@ jobs: sudo apt-get install -y cmake - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@4cda84d5c5c54efe2404f9d843567869ab1699d4 # stable + uses: dtolnay/rust-toolchain@4cda84d5c5c54efe2404f9d843567869ab1699d4 # stable (branch) with: targets: ${{ matrix.target }} @@ -125,14 +125,14 @@ jobs: upload-release-assets: false - name: Attest build provenance - uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4 + uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2 with: subject-path: | sysknife-${{ github.ref_name }}-linux-${{ matrix.arch }} sysknife-daemon-${{ github.ref_name }}-linux-${{ matrix.arch }} - name: Attest SBOM - uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4 + uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2 with: subject-path: | sysknife-${{ github.ref_name }}-linux-${{ matrix.arch }} @@ -140,7 +140,7 @@ jobs: sbom-path: sysknife-${{ github.ref_name }}-linux-${{ matrix.arch }}.spdx.json - name: Upload artifacts - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: binaries-${{ matrix.arch }} path: | @@ -160,10 +160,10 @@ jobs: steps: - name: Checkout - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Setup Node.js - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: '24' registry-url: 'https://registry.npmjs.org' @@ -192,10 +192,10 @@ jobs: steps: - name: Checkout - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@4cda84d5c5c54efe2404f9d843567869ab1699d4 # stable + uses: dtolnay/rust-toolchain@4cda84d5c5c54efe2404f9d843567869ab1699d4 # stable (branch) - name: Install system dependencies run: | @@ -235,10 +235,10 @@ jobs: steps: - name: Checkout - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Download all artifacts - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: merge-multiple: true path: dist/ diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml index 2b4c86ec..a5b71460 100644 --- a/.github/workflows/scorecard.yml +++ b/.github/workflows/scorecard.yml @@ -34,7 +34,7 @@ jobs: id-token: write # OIDC, required by publish_results steps: - name: Checkout - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false diff --git a/.github/workflows/secret-scan.yml b/.github/workflows/secret-scan.yml index d816f00b..8197b387 100644 --- a/.github/workflows/secret-scan.yml +++ b/.github/workflows/secret-scan.yml @@ -28,12 +28,12 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 # full clone so the base..head range resolves - name: TruffleHog (verified secrets only) - uses: trufflesecurity/trufflehog@05a583290be8c8f79852eb8741f5042920b47d00 # main + uses: trufflesecurity/trufflehog@05a583290be8c8f79852eb8741f5042920b47d00 # main (branch) with: # PR: scan base -> head. Schedule: omit range -> scan working tree. base: ${{ github.event.pull_request.base.sha || '' }} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8395ce23..f03f307f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -98,6 +98,19 @@ with work you have not committed or stashed. ### 2. Branch, code, test +Workflow action pins keep an exact tag in the adjacent comment, for example +`uses: actions/checkout@<40-hex SHA> # v7.0.1`. The tag must resolve to that +commit, including when it is annotated. Deliberate branch references use +`# stable (branch)` or `# main (branch)`; the verifier reports those without +comparing the pin to the moving branch head. Keep existing SHAs when correcting +comments; review action upgrades separately. + +Run `bash scripts/verify-action-pins.sh` to check these comments. It needs +authenticated `gh`, Python 3, and PyYAML (`python3 -m pip install PyYAML`, also +installed by yamllint). Both `docs-and-hygiene` and `scripts/ci-local.sh` require +this check, including a working GitHub API connection. Offline regression tests +run with `bash tests/release/action-pin-comments.test.sh`. + ```sh git checkout -b feat/ # … implement … diff --git a/scripts/action-pin-comments.py b/scripts/action-pin-comments.py new file mode 100644 index 00000000..0ad0b4e0 --- /dev/null +++ b/scripts/action-pin-comments.py @@ -0,0 +1,83 @@ +#!/usr/bin/env python3 +"""Extract pinned workflow references and their comments, preserving occurrences. + +Requires PyYAML (also installed by yamllint). This does not replace the release +rehearsal's SHA-pinning policy; it supplies the comment verifier's input. +""" + +import re +import sys +from pathlib import Path + +import yaml +from yaml.nodes import MappingNode, ScalarNode, SequenceNode + + +def entries(node, key): + if isinstance(node, MappingNode): + return [value for name, value in node.value if name.value == key] + return [] + + +def references(document): + for jobs in entries(document, "jobs"): + if not isinstance(jobs, MappingNode): + raise ValueError("jobs must be a mapping") + for _, job in jobs.value: + yield from entries(job, "uses") + for steps in entries(job, "steps"): + if not isinstance(steps, SequenceNode): + raise ValueError("steps must be a sequence") + for step in steps.value: + yield from entries(step, "uses") + + +def extract(root): + directory = root / ".github/workflows" + files = sorted(path for path in directory.iterdir() if path.suffix in (".yml", ".yaml")) + if not files: + raise ValueError("no workflow files") + rows = [] + for path in files: + try: + source = path.read_text(encoding="utf-8") + lines = source.splitlines() + # PyYAML omits comments, but token marks identify where YAML ends on + # each line, including quoted '#' characters and flow mappings. + ends = {} + for token in yaml.scan(source): + if token.start_mark.index == token.end_mark.index: + continue # Synthetic block/stream ends can follow comments. + mark = token.end_mark + ends[mark.line] = max(ends.get(mark.line, 0), mark.column) + for node in references(yaml.compose(source, Loader=yaml.SafeLoader)): + if not isinstance(node, ScalarNode): + raise ValueError("uses must be a scalar") + # Local and Docker references have no GitHub tag comment to check. + if node.value.startswith(("./", "docker://")): + continue + match = re.fullmatch(r"([\w.-]+/[\w./-]+)@([0-9a-fA-F]{40})", node.value) + if not match: + raise ValueError(f"cannot check non-SHA reference: {node.value}") + action, sha = match.groups() + line = node.end_mark.line + suffix = lines[line][ends[line]:] + comment = re.fullmatch(r"\s*#\s*(\S+(?: \(branch\))?)\s*", suffix) + if not comment: + raise ValueError(f"missing version comment or ambiguous comment for {action}") + location = f"{path.relative_to(root).as_posix()}:{node.start_mark.line + 1}" + rows.append("\t".join((action, sha.lower(), comment[1], location))) + except (OSError, UnicodeError, yaml.YAMLError, ValueError, IndexError) as error: + raise ValueError(f"cannot read workflow {path}: {error}") from error + if not rows: + raise ValueError("no pinned actions found") + # Emit only after every input has been read successfully. + print("\n".join(rows)) + + +if __name__ == "__main__": + sys.stdout.reconfigure(newline="\n") + try: + extract(Path(sys.argv[1])) + except (OSError, ValueError) as error: + sys.exit(f"verify-action-pins: {error}") diff --git a/scripts/ci-local.sh b/scripts/ci-local.sh index 0ec09919..d05338f7 100755 --- a/scripts/ci-local.sh +++ b/scripts/ci-local.sh @@ -289,6 +289,7 @@ run_hygiene_group() { run_step 'hygiene: firewall backend reporter fixtures' python3 "$repo_root/tests/test_firewall_state.py" run_step 'hygiene: check_repo_completeness.sh' bash "$repo_root/scripts/check_repo_completeness.sh" run_step 'hygiene: check_release_versions.sh' bash "$repo_root/scripts/check_release_versions.sh" + run_step 'hygiene: verify-action-pins.sh' bash "$repo_root/scripts/verify-action-pins.sh" "$repo_root" run_step 'hygiene: npm test --prefix packages/setup' npm test --prefix "$repo_root/packages/setup" run_shell_tests diff --git a/scripts/verify-action-pins.sh b/scripts/verify-action-pins.sh new file mode 100755 index 00000000..1ccd2db1 --- /dev/null +++ b/scripts/verify-action-pins.sh @@ -0,0 +1,89 @@ +#!/usr/bin/env bash +# Adapted from vladimirrott/maintainer-agent/scripts/verify-action-pins.sh. +# Upstream revision: 9d1dbd61d0ec3ef102fe2ed9dcd215d874388b1e +# +# MIT License +# Copyright (c) 2026 Vladimir Rotariu +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +# +# Preserve SHA pins; verify the tag named by each comment, peeling tag objects. +# `# stable (branch)` is a deliberate moving reference, reported without a +# comparison to today's branch head. Use exact release tags for other comments. +# Requires authenticated gh, Python 3 and PyYAML (provided by yamllint). +set -uo pipefail +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +root="${1:-$script_dir/..}" +rows="$(mktemp)" || exit 1 +trap 'rm -f "$rows"' EXIT +python3 "$script_dir/action-pin-comments.py" "$root" > "$rows" || exit 1 +fail=0 +count=0 +declare -A resolved + +deref() { # $1 repository, $2 tag -> commit (including nested annotated tags) + local obj type sha depth=0 + obj="$(gh api "repos/$1/git/ref/tags/$2" --jq '.object.type + " " + .object.sha')" || return 1 + while :; do + read -r type sha <<< "$obj" + [[ "$sha" =~ ^[0-9a-f]{40}$ ]] || return 1 + case "$type" in + commit) printf '%s' "$sha"; return 0 ;; + tag) + depth=$((depth + 1)) + ((depth <= 16)) || return 1 + obj="$(gh api "repos/$1/git/tags/$sha" --jq '.object.type + " " + .object.sha')" || return 1 + ;; + *) return 1 ;; + esac + done +} + +while IFS=$'\t' read -r action sha claim location; do + count=$((count + 1)) + if [[ "$claim" == *' (branch)' ]]; then + printf ' BRANCH %-46s tracks %s by design (%s)\n' "$action" "${claim% (branch)}" "$location" + continue + fi + # Action subpaths (e.g. codeql-action/analyze) share the repository's tags. + owner="${action%%/*}" + rest="${action#*/}" + repo="$owner/${rest%%/*}" + key="$repo@$claim" + if [[ -z "${resolved[$key]+present}" ]]; then + if real="$(deref "$repo" "$claim")"; then + resolved[$key]="$real" + else + resolved[$key]='ERROR' + fi + fi + real="${resolved[$key]}" + if [[ "$real" == ERROR ]]; then + printf ' ERROR %-46s cannot resolve tag %s; check ref, authentication and API availability (%s)\n' "$action" "$claim" "$location" + fail=1 + elif [[ "$real" == "$sha" ]]; then + printf ' OK %-46s %s is %s (%s)\n' "$action" "${sha:0:8}" "$claim" "$location" + else + printf ' FAIL %-46s pinned %s but %s is %s (%s)\n' "$action" "${sha:0:8}" "$claim" "${real:0:8}" "$location" + fail=1 + fi +done < "$rows" +((count > 0)) || { echo 'verify-action-pins: no pinned actions' >&2; exit 1; } +printf 'verify-action-pins: checked %d pins\n' "$count" +exit "$fail" diff --git a/tests/release/action-pin-comments.test.sh b/tests/release/action-pin-comments.test.sh new file mode 100755 index 00000000..57f9b1dd --- /dev/null +++ b/tests/release/action-pin-comments.test.sh @@ -0,0 +1,76 @@ +#!/usr/bin/env bash +# Offline contract tests: no GitHub credentials or network access. +set -euo pipefail +root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +tmp="$(mktemp -d)" +trap 'rm -rf "$tmp"' EXIT +mkdir -p "$tmp/bin" "$tmp/repo/.github/workflows" +export PATH="$tmp/bin:$PATH" PIN_API_LOG="$tmp/api.log" +cat > "$tmp/bin/gh" <<'STUB' +#!/usr/bin/env bash +printf '%s\n' "$2" >> "$PIN_API_LOG" +case "$2" in + repos/actions/checkout/git/ref/tags/v1.2.3) echo 'commit aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' ;; + repos/actions/checkout/git/ref/tags/v9.9.9) echo 'commit bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' ;; + repos/github/codeql-action/git/ref/tags/v4.37.9) echo 'tag cccccccccccccccccccccccccccccccccccccccc' ;; + repos/github/codeql-action/git/tags/cccccccccccccccccccccccccccccccccccccccc) echo 'tag dddddddddddddddddddddddddddddddddddddddd' ;; + repos/github/codeql-action/git/tags/dddddddddddddddddddddddddddddddddddddddd) echo 'commit aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' ;; + *) echo 'HTTP 404 or API unavailable (fixture)' >&2; exit 1 ;; +esac +STUB +chmod +x "$tmp/bin/gh" +workflow="$tmp/repo/.github/workflows/test.yaml" +cat > "$workflow" <<'YAML' +jobs: + test: + steps: + - uses: actions/checkout@aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa # v1.2.3 + - uses: github/codeql-action/analyze@aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa # v4.37.9 + - uses: + actions/checkout@aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa # v1.2.3 + - {uses: 'actions/checkout@aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', name: 'quoted # text'} # v1.2.3 + - uses: dtolnay/rust-toolchain@aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa # stable (branch) + - uses: ./local-action + - run: | + echo 'uses: fake/action@aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' +YAML +check() { bash "$root/scripts/verify-action-pins.sh" "$tmp/repo" > "$tmp/out" 2>&1; } +check || { cat "$tmp/out"; exit 1; } +[[ "$(grep -c '^ OK ' "$tmp/out")" = 4 ]] +[[ "$(grep -c '^ BRANCH ' "$tmp/out")" = 1 ]] +grep -q 'checked 5 pins' "$tmp/out" +grep -q 'repos/github/codeql-action/git/tags/dddd' "$PIN_API_LOG" +[[ "$(grep -c 'repos/actions/checkout/git/ref/tags/v1.2.3' "$PIN_API_LOG")" = 1 ]] +! grep -q 'rust-toolchain' "$PIN_API_LOG" +cp "$workflow" "$tmp/good" +expect_failure() { + if check; then echo 'checker unexpectedly passed'; cat "$tmp/out"; exit 1; fi + grep -q "$1" "$tmp/out" || { cat "$tmp/out"; exit 1; } +} +sed 's/# v1.2.3/# v9.9.9/g' "$tmp/good" > "$workflow" +expect_failure 'FAIL.*actions/checkout.*pinned' +cp "$tmp/good" "$workflow" +check || { cat "$tmp/out"; exit 1; } +sed 's/# v1.2.3/# v0.0.0/g' "$tmp/good" > "$workflow" +expect_failure 'ERROR.*actions/checkout.*cannot resolve' +sed 's/# stable (branch)/# stable/' "$tmp/good" > "$workflow" +expect_failure 'ERROR.*rust-toolchain' +sed 's/# v1.2.3//g' "$tmp/good" > "$workflow" +expect_failure 'missing version comment' +printf 'jobs: {test: {uses: "actions/checkout@aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}} # v1.2.3' > "$workflow" +check || { cat "$tmp/out"; exit 1; } +grep -q 'checked 1 pins' "$tmp/out" +printf 'jobs: {}\n' > "$workflow" +expect_failure 'no pinned actions' +printf 'jobs: [\n' > "$workflow" +expect_failure 'cannot read workflow' +rm "$workflow" +expect_failure 'no workflow files' +mkdir "$workflow" +expect_failure 'cannot read workflow' +rmdir "$workflow" +cp "$tmp/good" "$workflow" +# A valid workflow must not hide another input that cannot be opened. +mkdir "$tmp/repo/.github/workflows/unreadable.yml" +expect_failure 'cannot read workflow' +echo 'action pin comment fixtures passed' diff --git a/tests/release/ci-local.test.sh b/tests/release/ci-local.test.sh index 325526c1..84b94307 100755 --- a/tests/release/ci-local.test.sh +++ b/tests/release/ci-local.test.sh @@ -33,6 +33,23 @@ def bash(code, root=ROOT): class LocalGates(unittest.TestCase): + def test_action_pin_verifier_failure_is_a_hard_gate(self): + code = functions('record', 'run_step', 'run_hygiene_group') + ''' +RESULTS=(); hard_failures=0 +have() { return 1; } +python3() { :; } +npm() { :; } +run_shell_tests() { :; } +bash() { [[ "$1" != */verify-action-pins.sh ]]; } +run_hygiene_group +printf 'failures=%s\\n' "$hard_failures" +printf '%s\\n' "${RESULTS[@]}" +''' + result = bash(code) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn('failures=1', result.stdout) + self.assertIn('FAIL hygiene: verify-action-pins.sh', result.stdout) + def test_hygiene_runs_every_discovered_and_ci_shell_test(self): code = functions('run_hygiene_group') # Include any extracted discovery function: the real hygiene entrypoint