Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions .github/workflows/conventional-commits.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
2 changes: 1 addition & 1 deletion .github/workflows/flake-closure-diff.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/release-drafter.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/renovate-dev-kit-lock.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand Down
8 changes: 4 additions & 4 deletions .github/workflows/test-actions.yml
Original file line number Diff line number Diff line change
Expand Up @@ -35,15 +35,15 @@ 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

test-scripts:
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
Expand All @@ -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:
Expand All @@ -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:
Expand Down
72 changes: 71 additions & 1 deletion .github/workflows/update-action-pins.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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: |
Expand All @@ -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 `# <version>` 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 `@<sha> # <version>` 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: <action>@<sha> # <version>', 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/<owner>/<repo>'"
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
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -368,6 +368,17 @@ jobs:
uses: opendefensecloud/dev-kit/.github/workflows/update-action-pins.yml@<40-char-sha> # <tag>
```

It also fails when a pin's comment is anything but its version
(`uses: <action>@<sha> # <version>`): 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
Expand Down
14 changes: 7 additions & 7 deletions docs/NEW_REPO.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

Expand Down
141 changes: 127 additions & 14 deletions scripts/test-update-action-pins.sh
Original file line number Diff line number Diff line change
@@ -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 <name>: 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 <pass|fail> <description> <file under .github/> <content>
# report <want> <got> <description>
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 <pass|fail> <description> <file under .github/> <content>: 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"
Expand All @@ -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 <pass|fail> <description> <base content> <head content>: 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"
Loading