diff --git a/.github/workflows/conventional-commits.yml b/.github/workflows/conventional-commits.yml index b3a45fd..8a1f0c3 100644 --- a/.github/workflows/conventional-commits.yml +++ b/.github/workflows/conventional-commits.yml @@ -14,7 +14,7 @@ jobs: name: PR Title runs-on: ubuntu-24.04 steps: - - uses: amannn/action-semantic-pull-request@48f256284bd46cdaab1048c3721360e808335d50 # v6 + - uses: amannn/action-semantic-pull-request@48f256284bd46cdaab1048c3721360e808335d50 # v6.1.1 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: @@ -34,10 +34,10 @@ jobs: name: Commit Messages runs-on: ubuntu-24.04 steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 persist-credentials: false - - uses: wagoid/commitlint-github-action@b948419dd99f3fd78a6548d48f94e3df7f6bf3ed # v6 + - uses: wagoid/commitlint-github-action@b948419dd99f3fd78a6548d48f94e3df7f6bf3ed # v6.2.1 with: configFile: .commitlintrc.yml diff --git a/.github/workflows/flake-closure-diff.yml b/.github/workflows/flake-closure-diff.yml index 1460e46..3dc91e4 100644 --- a/.github/workflows/flake-closure-diff.yml +++ b/.github/workflows/flake-closure-diff.yml @@ -39,7 +39,7 @@ jobs: name: Diff flake closures runs-on: ${{ inputs.runs-on }} steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 persist-credentials: false diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 92bc1cb..6ace326 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -16,7 +16,7 @@ jobs: run: shell: nix develop --command bash -e {0} steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - name: Set up nix with the shared Cachix cache diff --git a/.github/workflows/release-drafter.yaml b/.github/workflows/release-drafter.yaml index d8fc6ea..868fe85 100644 --- a/.github/workflows/release-drafter.yaml +++ b/.github/workflows/release-drafter.yaml @@ -33,7 +33,7 @@ jobs: # echo "GHE_HOST=${GITHUB_SERVER_URL##https:\/\/}" >> $GITHUB_ENV # Drafts your next Release notes as Pull Requests are merged into "master" - - uses: release-drafter/release-drafter@978bb0b30c5ee8cdd332ced253758157150d43f0 # v7 + - uses: release-drafter/release-drafter@978bb0b30c5ee8cdd332ced253758157150d43f0 # v7.10.0 # (Optional) specify config name to use, relative to .github/. Default: release-drafter.yml # with: # config-name: my-config.yml diff --git a/.github/workflows/renovate-dev-kit-lock.yml b/.github/workflows/renovate-dev-kit-lock.yml index 6b23c3c..5015a54 100644 --- a/.github/workflows/renovate-dev-kit-lock.yml +++ b/.github/workflows/renovate-dev-kit-lock.yml @@ -52,13 +52,13 @@ jobs: group: renovate-dev-kit-lock-${{ github.event.pull_request.number }} cancel-in-progress: true steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ github.event.pull_request.head.sha }} path: repo persist-credentials: false # The relock script, from the dev-kit commit this workflow was called at. - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: ${{ job.workflow_repository }} ref: ${{ job.workflow_sha }} diff --git a/.github/workflows/test-actions.yml b/.github/workflows/test-actions.yml index 514e125..2128f8d 100644 --- a/.github/workflows/test-actions.yml +++ b/.github/workflows/test-actions.yml @@ -35,7 +35,7 @@ jobs: shellcheck: runs-on: ubuntu-24.04 steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Run ShellCheck on action and dev-kit scripts run: shellcheck .github/actions/*/*.sh scripts/*.sh @@ -43,7 +43,7 @@ jobs: needs: shellcheck runs-on: ubuntu-24.04 steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Test verify-clean.sh run: .github/actions/diff-check/test-verify-clean.sh - name: Test extract-go-version.sh @@ -64,7 +64,7 @@ jobs: needs: shellcheck runs-on: ubuntu-24.04 steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Set up nix with the shared Cachix cache uses: ./.github/actions/setup-nix with: @@ -80,7 +80,7 @@ jobs: needs: shellcheck runs-on: ubuntu-24.04 steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Set up nix with the shared Cachix cache uses: ./.github/actions/setup-nix with: diff --git a/.github/workflows/update-action-pins.yml b/.github/workflows/update-action-pins.yml index f999b70..b02fd85 100644 --- a/.github/workflows/update-action-pins.yml +++ b/.github/workflows/update-action-pins.yml @@ -13,13 +13,23 @@ on: pull_request: workflow_call: +# `bash` runs with pipefail, unlike the default `bash -e`: a failing git grep at +# the head of a pipeline must fail the step, not leave an empty result that +# reads as "nothing found". +defaults: + run: + shell: bash + jobs: check-pins: name: Check action pins runs-on: ubuntu-24.04 steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: + # The PR's merge commit and its first parent, the base branch, for + # the moved-tag check. + fetch-depth: 2 persist-credentials: false - name: Verify all actions are pinned to a SHA run: | @@ -41,3 +51,63 @@ jobs: echo "Run 'GITHUB_TOKEN=\$(gh auth token) update-action-pins .github/' to fix." exit 1 fi + - name: Verify every pin carries only its version comment + run: | + # zizmor's ref-version-mismatch audit compares the comment with the + # SHA only when the comment is `# ` and nothing else: without + # a comment, or with any text after the version, it skips the pin + # without a finding. That includes `# zizmor: ignore[...]`, so no + # ignore comment can sit next to a pin. Both patterns are anchored to + # the `uses:` value, so an `@ # ` later in the comment + # doesn't count. + pin='^[[:space:]]*(-[[:space:]]+)?uses:[[:space:]]*[^@[:space:]]+@[0-9a-f]{40}' + malformed=$(grep -rhE '^[[:space:]]*(-[[:space:]]+)?uses:' .github/ \ + | tr -d "\"'" \ + | grep -E "$pin([[:space:]]|\$)" \ + | grep -vE "$pin[[:space:]]+#[[:space:]]+[[:alnum:]._+/-]+[[:space:]]*\$" || true) + if [[ -n "$malformed" ]]; then + echo "::error::Found pinned actions whose comment is not just their version:" + echo "$malformed" + echo "" + echo "Write the pin as 'uses: @ # ', with nothing after the version." + exit 1 + fi + - name: Verify no pin moved to a new commit under the same version + if: github.event_name == 'pull_request' + run: | + # A pin whose SHA changes while its version comment stays the same + # points at new code under a version that was already reviewed. Either + # upstream moved the release tag, which is how a compromised tag + # reaches a digest update, or the PR swapped the SHA and kept the + # comment. zizmor can't tell: the moved tag still matches the comment. + # Compares the merge commit (HEAD) with the base branch (HEAD^1). + # Without HEAD^1 the check can't compare; say why instead of failing + # on git grep's error. + if ! git rev-parse -q --verify 'HEAD^1^{commit}' > /dev/null; then + echo "::error::Base commit HEAD^1 not found; the check needs the PR's merge commit and its parents." + exit 1 + fi + # GitHub resolves owner and repo case-insensitively, so lowercase the + # line: `Actions/Checkout` would otherwise not match `actions/checkout`. + # git grep exits 1 when nothing matches, which isn't an error. + pins() { + { git grep -hE '^[[:space:]]*(-[[:space:]]+)?uses:' "$1" -- .github/ || (($? == 1)); } \ + | tr -d "\"'" \ + | tr '[:upper:]' '[:lower:]' \ + | sed -nE 's/^.*uses:[[:space:]]*([^@[:space:]]+)@([0-9a-f]{40})[[:space:]]+#[[:space:]]*([^[:space:]]+).*$/\1#\3 \2/p' \ + | sort -u + } + # Only pins the PR adds count, so a version the base branch already + # pins to several SHAs doesn't fail every PR, nor the one fixing it. + base=$(pins HEAD^1) + moved=$(join <(printf '%s\n' "$base") <(comm -13 <(printf '%s\n' "$base") <(pins HEAD)) \ + | awk '{ sub("#", " # ", $1); print $1 ": " $2 " -> " $3 }') + if [[ -n "$moved" ]]; then + echo "::error::Found action pins whose SHA changed but whose version comment did not:" + echo "$moved" + echo "" + echo "A new SHA needs a new version: 'git ls-remote --tags https://github.com//'" + echo "lists the tags pointing at it. If upstream moved the tag on purpose, review the" + echo "new commit; where this check is required, merging past it needs an admin." + exit 1 + fi diff --git a/README.md b/README.md index 42cfcf6..77485b3 100644 --- a/README.md +++ b/README.md @@ -368,6 +368,17 @@ jobs: uses: opendefensecloud/dev-kit/.github/workflows/update-action-pins.yml@<40-char-sha> # ``` +It also fails when a pin's comment is anything but its version +(`uses: @ # `): zizmor checks the version against the SHA +only in that form, and skips a pin without a comment or with more text after +the version, a `# zizmor: ignore[...]` included. + +On pull requests it also fails when a pin's SHA changes but its version comment +does not: new code under a version that was already reviewed means upstream +moved the tag, or the PR swapped the SHA. Pins should name a full release tag +(`# v7.0.1`, not `# v7`), since a major tag moves with every release and would +trip the check on each update. + `.github/workflows/conventional-commits.yml`: ```yaml diff --git a/docs/NEW_REPO.md b/docs/NEW_REPO.md index 6b606a3..48a2a49 100644 --- a/docs/NEW_REPO.md +++ b/docs/NEW_REPO.md @@ -61,13 +61,13 @@ If your project uses private runners, whitelist the repository in the runner gro Copy the relevant workflows from `.github/workflows/` in this repository: -| Workflow | Purpose | -| --------------------------- | -------------------------------------------------------------------- | -| `conventional-commits.yml` | Validates PR titles and commit messages against Conventional Commits | -| `issues-add-labels.yaml` | Automatically adds `needs-triage` label to new issues | -| `issues-add-to-project.yml` | Adds new issues and PRs to the org project board | -| `release-drafter.yaml` | Drafts release notes from merged PRs | -| `update-action-pins.yml` | Fails the PR if any action is not pinned to a SHA | +| Workflow | Purpose | +| --------------------------- | ----------------------------------------------------------------------------------------------------------------------- | +| `conventional-commits.yml` | Validates PR titles and commit messages against Conventional Commits | +| `issues-add-labels.yaml` | Automatically adds `needs-triage` label to new issues | +| `issues-add-to-project.yml` | Adds new issues and PRs to the org project board | +| `release-drafter.yaml` | Drafts release notes from merged PRs | +| `update-action-pins.yml` | Fails the PR if any action is not pinned to a SHA with a version comment, or a pin's SHA changes under the same version | If using release-drafter, also copy `.github/release-drafter.yml` (the config file). diff --git a/scripts/test-update-action-pins.sh b/scripts/test-update-action-pins.sh index 7fd8fa4..151b6f7 100755 --- a/scripts/test-update-action-pins.sh +++ b/scripts/test-update-action-pins.sh @@ -1,34 +1,57 @@ #!/usr/bin/env bash set -euo pipefail -# Runs the pin check from update-action-pins.yml itself (extracted with yq), so +# Runs the checks from update-action-pins.yml itself (extracted with yq), so # the test cannot drift from what CI runs. ROOT="$(cd "$(dirname "$0")/.." && pwd)" -CHECK="$(yq '.jobs.check-pins.steps[] | select(.name == "Verify all actions are pinned to a SHA") | .run' \ - "$ROOT/.github/workflows/update-action-pins.yml")" -[[ -n "$CHECK" ]] || { - echo "FAIL: pin check step not found" - exit 1 + +# Keep the developer's git config (commit signing, hooks) out of the test repos. +export GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_NOSYSTEM=1 + +# step : prints the run script of the check-pins step with that name. +step() { + local run + run="$(yq ".jobs.check-pins.steps[] | select(.name == \"$1\") | .run" \ + "$ROOT/.github/workflows/update-action-pins.yml")" + [[ -n "$run" ]] || { + echo "FAIL: step '$1' not found" >&2 + exit 1 + } + printf '%s\n' "$run" } +CHECK="$(step "Verify all actions are pinned to a SHA")" +COMMENT_CHECK="$(step "Verify every pin carries only its version comment")" +MOVE_CHECK="$(step "Verify no pin moved to a new commit under the same version")" + SHA=0123456789abcdef0123456789abcdef01234567 fail=0 -# expect +# report +report() { + if [[ "$2" == "$1" ]]; then + echo "ok $3" + else + echo "FAIL $3: expected $1, got $2" + fail=1 + fi +} + +# Steps run as `bash -eo pipefail`, which `shell: bash` gives in CI. +report bash "$(yq '.defaults.run.shell' "$ROOT/.github/workflows/update-action-pins.yml")" \ + "steps run with pipefail" + +# expect : runs the +# SHA check, or the step in $RUN when set. expect() { local want=$1 desc=$2 file=$3 content=$4 dir got dir="$(mktemp -d)" mkdir -p "$dir/.github/$(dirname "$file")" printf '%s\n' "$content" > "$dir/.github/$file" - if (cd "$dir" && bash -c "$CHECK") > /dev/null 2>&1; then got=pass; else got=fail; fi + if (cd "$dir" && bash -eo pipefail -c "${RUN:-$CHECK}") > /dev/null 2>&1; then got=pass; else got=fail; fi rm -rf "$dir" - if [[ "$got" == "$want" ]]; then - echo "ok $desc" - else - echo "FAIL $desc: expected $want, got $got" - fail=1 - fi + report "$want" "$got" "$desc" } expect pass "pinned action" workflows/a.yml " - uses: actions/checkout@$SHA # v7" @@ -45,4 +68,94 @@ expect fail "unpinned composite action" actions/foo/action.yml " - uses: acti expect fail "SHA only in the comment" workflows/a.yml " - uses: actions/checkout@main # @$SHA" expect fail "short SHA" workflows/a.yml " - uses: actions/checkout@${SHA:0:7}" +RUN=$COMMENT_CHECK +expect pass "version comment" workflows/a.yml " - uses: actions/checkout@$SHA # v7.0.1" +expect pass "quoted pin with a version comment" workflows/a.yml " - uses: \"actions/checkout@$SHA\" # v7.0.1" +expect pass "version without a v prefix" workflows/a.yml " - uses: foo/bar@$SHA # 2.37.0" +expect pass "local action without a comment" workflows/a.yml " - uses: ./.github/actions/foo" +expect pass "unpinned action, left to the SHA check" workflows/a.yml " - uses: actions/checkout@v7" +expect fail "no comment" workflows/a.yml " - uses: actions/checkout@$SHA" +expect fail "empty comment" workflows/a.yml " - uses: actions/checkout@$SHA #" +expect fail "no space after the hash" workflows/a.yml " - uses: actions/checkout@$SHA #v7.0.1" +expect fail "zizmor ignore after the version" workflows/a.yml \ + " - uses: actions/checkout@$SHA # v7.0.1 # zizmor: ignore[known-vulnerable-actions]" +expect fail "zizmor ignore instead of the version" workflows/a.yml \ + " - uses: actions/checkout@$SHA # zizmor: ignore[known-vulnerable-actions]" +expect fail "another pin and version at the end of the comment" workflows/a.yml \ + " - uses: actions/checkout@$SHA # v7.0.1 # zizmor: ignore[ref-version-mismatch] @$SHA # v7.0.1" +expect fail "another uses: in the comment" workflows/a.yml \ + " - uses: actions/checkout@$SHA # v7.0.1 # uses: actions/checkout@$SHA # v7.0.1" +expect pass "unpinned action with a pin in the comment, left to the SHA check" workflows/a.yml \ + " - uses: actions/checkout@v7 # @$SHA # v7.0.1" +unset RUN + +OTHER=fedcba9876543210fedcba9876543210fedcba98 + +# expect_move : commits +# both versions of .github/workflows/a.yml and runs the moved-tag check on them. +expect_move() { + local want=$1 desc=$2 base=$3 head=$4 dir got + dir="$(mktemp -d)" + mkdir -p "$dir/.github/workflows" + ( + cd "$dir" + git init -q + printf '%s\n' "$base" > .github/workflows/a.yml + git add -A && git -c user.name=t -c user.email=t@t commit -qm base + printf '%s\n' "$head" > .github/workflows/a.yml + git add -A && git -c user.name=t -c user.email=t@t commit -q --allow-empty -m head + ) + if (cd "$dir" && bash -eo pipefail -c "$MOVE_CHECK") > /dev/null 2>&1; then got=pass; else got=fail; fi + rm -rf "$dir" + report "$want" "$got" "$desc" +} + +expect_move pass "unchanged pin" \ + " - uses: actions/checkout@$SHA # v7.0.1" \ + " - uses: actions/checkout@$SHA # v7.0.1" +expect_move pass "new SHA with a new version" \ + " - uses: actions/checkout@$SHA # v7.0.1" \ + " - uses: actions/checkout@$OTHER # v7.0.2" +expect_move pass "same version of another action" \ + " - uses: foo/bar@$SHA # v1.0.0" \ + "$(printf ' - uses: foo/bar@%s # v1.0.0\n - uses: foo/baz@%s # v1.0.0' "$SHA" "$OTHER")" +expect_move fail "new SHA under the same version" \ + " - uses: actions/checkout@$SHA # v7.0.1" \ + " - uses: actions/checkout@$OTHER # v7.0.1" +expect_move fail "quoted pin with a new SHA" \ + " - uses: \"actions/checkout@$SHA\" # v7.0.1" \ + " - uses: 'actions/checkout@$OTHER' # v7.0.1" +expect_move fail "new SHA under another case of the action name" \ + " - uses: actions/checkout@$SHA # v7.0.1" \ + " - uses: Actions/Checkout@$OTHER # v7.0.1" +expect_move fail "reusable workflow with a new SHA" \ + " uses: org/repo/.github/workflows/x.yml@$SHA # v1.2.3" \ + " uses: org/repo/.github/workflows/x.yml@$OTHER # v1.2.3" +expect_move fail "second use of the same version with a new SHA" \ + " - uses: actions/checkout@$SHA # v7.0.1" \ + "$(printf ' - uses: actions/checkout@%s # v7.0.1\n - uses: actions/checkout@%s # v7.0.1' "$SHA" "$OTHER")" +expect_move pass "version already on two SHAs in the base, untouched" \ + "$(printf ' - uses: actions/checkout@%s # v7.0.1\n - uses: actions/checkout@%s # v7.0.1' "$SHA" "$OTHER")" \ + "$(printf ' - uses: actions/checkout@%s # v7.0.1\n - uses: actions/checkout@%s # v7.0.1' "$SHA" "$OTHER")" +expect_move pass "version already on two SHAs in the base, collapsed to one" \ + "$(printf ' - uses: actions/checkout@%s # v7.0.1\n - uses: actions/checkout@%s # v7.0.1' "$SHA" "$OTHER")" \ + " - uses: actions/checkout@$SHA # v7.0.1" +expect_move pass "first pin, none in the base" \ + "on: push" \ + " - uses: actions/checkout@$SHA # v7.0.1" + +# Without a base commit the check can't compare, so it must fail rather than +# treat every pin as new. +dir="$(mktemp -d)" +mkdir -p "$dir/.github/workflows" +( + cd "$dir" + git init -q + printf '%s\n' " - uses: actions/checkout@$OTHER # v7.0.1" > .github/workflows/a.yml + git add -A && git -c user.name=t -c user.email=t@t commit -qm head +) +if (cd "$dir" && bash -eo pipefail -c "$MOVE_CHECK") > /dev/null 2>&1; then got=pass; else got=fail; fi +rm -rf "$dir" +report fail "$got" "no base commit" + exit "$fail"