From 405893a63d09d40582ac5a12c9805936cd333060 Mon Sep 17 00:00:00 2001 From: Marco Walz Date: Thu, 17 Sep 2026 10:47:43 +0200 Subject: [PATCH 1/5] ci: fail when a workflow pins a stale copy of an action Workflows reference this repository's own actions by commit SHA, and those pins do not move when an action changes, so a workflow keeps running the previous copy of it. Passing a newly added input to such an action is reported as a warning rather than an error, so the run stays green while the new behaviour does nothing. actions/create-pr was already a commit behind, which meant the bundle rebuilt in #79 was not the one the changelog workflow ran. Co-Authored-By: Claude Opus 5 (1M context) --- .github/CONTRIBUTING.md | 12 +++ .github/workflows/check-commit-messages.yaml | 6 +- .github/workflows/check-pr-title.yaml | 6 +- .github/workflows/self-check-self-refs.yaml | 26 +++++ README.md | 11 ++ script/bump-self-refs | 108 +++++++++++++++++++ 6 files changed, 163 insertions(+), 6 deletions(-) create mode 100644 .github/workflows/self-check-self-refs.yaml create mode 100755 script/bump-self-refs diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 3302153..a9d7bd7 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -129,3 +129,15 @@ pnpm test ``` Tests live next to the code they cover, as `*.test.ts`. + +## Workflow self references + +The workflows in this repository reference its own actions by commit SHA, exactly as a consuming repository does. Those pins do not move on their own, so after changing an action the workflows still run the previous copy of it until they are repointed. + +`Check Self References` runs on every push to `main` and fails when a pin has fallen behind the action it points at. To resolve it: + +```bash +script/bump-self-refs --fix +``` + +Commit the result and open a pull request. The check only compares a pin against the history of the action's own directory, so repointing the workflows does not itself make them stale again. diff --git a/.github/workflows/check-commit-messages.yaml b/.github/workflows/check-commit-messages.yaml index 4288f45..90bc385 100644 --- a/.github/workflows/check-commit-messages.yaml +++ b/.github/workflows/check-commit-messages.yaml @@ -19,12 +19,12 @@ jobs: fetch-depth: 0 - name: Setup Python - uses: dfinity/ci-tools/actions/setup-python@afeee4fbdc0683a88ec5a74ed7f59a2ce0e833ad # main + uses: dfinity/ci-tools/actions/setup-python@4b4a84c58faa93db27c778a898e5a941948b3511 # main - name: Setup Commitizen - uses: dfinity/ci-tools/actions/setup-commitizen@afeee4fbdc0683a88ec5a74ed7f59a2ce0e833ad # main + uses: dfinity/ci-tools/actions/setup-commitizen@4b4a84c58faa93db27c778a898e5a941948b3511 # main - name: Check commit messages - uses: dfinity/ci-tools/actions/check-commit-messages@afeee4fbdc0683a88ec5a74ed7f59a2ce0e833ad # main + uses: dfinity/ci-tools/actions/check-commit-messages@4b4a84c58faa93db27c778a898e5a941948b3511 # main with: target_branch: ${{ inputs.target_branch }} diff --git a/.github/workflows/check-pr-title.yaml b/.github/workflows/check-pr-title.yaml index cf2dd09..c038be3 100644 --- a/.github/workflows/check-pr-title.yaml +++ b/.github/workflows/check-pr-title.yaml @@ -8,10 +8,10 @@ jobs: runs-on: ubuntu-latest steps: - name: Setup Python - uses: dfinity/ci-tools/actions/setup-python@afeee4fbdc0683a88ec5a74ed7f59a2ce0e833ad # main + uses: dfinity/ci-tools/actions/setup-python@4b4a84c58faa93db27c778a898e5a941948b3511 # main - name: Setup Commitizen - uses: dfinity/ci-tools/actions/setup-commitizen@afeee4fbdc0683a88ec5a74ed7f59a2ce0e833ad # main + uses: dfinity/ci-tools/actions/setup-commitizen@4b4a84c58faa93db27c778a898e5a941948b3511 # main - name: Check pull request title - uses: dfinity/ci-tools/actions/check-pr-title@afeee4fbdc0683a88ec5a74ed7f59a2ce0e833ad # main + uses: dfinity/ci-tools/actions/check-pr-title@4b4a84c58faa93db27c778a898e5a941948b3511 # main diff --git a/.github/workflows/self-check-self-refs.yaml b/.github/workflows/self-check-self-refs.yaml new file mode 100644 index 0000000..062348b --- /dev/null +++ b/.github/workflows/self-check-self-refs.yaml @@ -0,0 +1,26 @@ +name: Check Self References + +on: + push: + branches: + - main + workflow_dispatch: + +concurrency: + group: main-${{ github.workflow }} + cancel-in-progress: true + +jobs: + check_self_refs: + name: check_self_refs + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + # The check compares each pinned commit against the history of the + # action it points at, so the full history is needed. + fetch-depth: 0 + + - name: Check workflow self references + run: script/bump-self-refs diff --git a/README.md b/README.md index 026d435..2218ad0 100644 --- a/README.md +++ b/README.md @@ -69,6 +69,17 @@ jobs: uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 ``` +### Referencing your own actions + +A reusable workflow that other repositories call must reference the actions in its own repository the same fully qualified, pinned way. A relative `./` path resolves against the caller's workspace rather than the repository the workflow lives in, so it fails there, and silently runs the caller's own file if one happens to sit at that path. Pinning keeps a single reference from a consuming repository resolving to a coherent set of workflows and actions. + +The cost is that those pins do not move when an action changes, which leaves a workflow running an older copy of an action than the one beside it in the tree. That failure is quiet: a newly added input handed to an action pinned from before it existed is reported as a warning, not an error, so the run stays green while the new behaviour does nothing. Pair the convention with a job that fails when a pin has fallen behind: + +```yaml +- name: Check workflow self references + run: script/bump-self-refs +``` + ## Managing Concurrency For workflows that run on pull requests, use the `concurrency` key to ensure that only one workflow runs at a time for a given pull request. This prevents multiple workflows from running simultaneously and potentially causing conflicts. diff --git a/script/bump-self-refs b/script/bump-self-refs new file mode 100755 index 0000000..8ed5fa9 --- /dev/null +++ b/script/bump-self-refs @@ -0,0 +1,108 @@ +#!/bin/bash + +# About: +# +# The workflows in this repository reference this repository's own actions by +# commit SHA, the same way other repositories reference them. That is what makes +# a single pinned reference resolve to a coherent set of workflows and actions, +# but those pins do not move when an action changes, so a workflow can keep +# running an older copy of an action than the one next to it in the tree. +# +# The failure is quiet. A workflow that passes a newly added input to an action +# pinned from before that input existed gets a warning rather than an error, so +# the run stays green while the new behaviour does nothing at all. +# +# This script reports every self reference whose action has changed since the +# commit it points at, and exits non-zero when it finds one. +# +# Usage: +# +# script/bump-self-refs report stale references +# script/bump-self-refs --fix repoint every self reference at HEAD + +set -euo pipefail + +REPO='dfinity/ci-tools' +WORKFLOW_DIR='.github/workflows' +REF_PATTERN="${REPO}/[A-Za-z0-9._/-]+@[0-9a-f]{40}" + +fix=false +if [ "${1:-}" = '--fix' ]; then + fix=true +elif [ -n "${1:-}" ]; then + echo "Unknown argument '$1'. Usage: script/bump-self-refs [--fix]" >&2 + exit 2 +fi + +cd "$(git rev-parse --show-toplevel)" + +# Built with a read loop rather than mapfile, which the bash 3.2 that ships with +# macOS does not have. +workflows=() +while IFS= read -r workflow; do + workflows+=("$workflow") +done < <(find "$WORKFLOW_DIR" -type f \( -name '*.yaml' -o -name '*.yml' \) | sort) + +if [ -z "${workflows[0]:-}" ]; then + echo "No workflows found in $WORKFLOW_DIR" >&2 + exit 2 +fi + +head_sha=$(git rev-parse HEAD) + +refs=$(grep -hoE "$REF_PATTERN" "${workflows[@]}" | sort -u || true) +if [ -z "$refs" ]; then + echo "No $REPO self references found." + exit 0 +fi + +stale_count=0 +while IFS= read -r ref; do + sha="${ref##*@}" + path="${ref%@*}" + path="${path#"$REPO"/}" + + if [ "$sha" = "$head_sha" ]; then + continue + fi + + # A pin to a commit that cannot be resolved is a problem in its own right, and + # there is nothing to compare it against. + if ! git cat-file -e "${sha}^{commit}" 2>/dev/null; then + echo " ? ${path} pinned at ${sha:0:8}, which is not a known commit" + stale_count=$((stale_count + 1)) + continue + fi + + commits=$(git rev-list --count "${sha}..HEAD" -- "$path") + if [ "$commits" -gt 0 ]; then + echo " x ${path} pinned at ${sha:0:8}, ${commits} commit(s) behind" + stale_count=$((stale_count + 1)) + fi +done <&2 < Date: Thu, 17 Sep 2026 11:10:49 +0200 Subject: [PATCH 2/5] docs: note the bump constraints on the self reference check A bump has to be its own pull request, because --fix can only pin to a commit that already exists. And the check cannot be a required status check, since it only reports after a merge. Co-Authored-By: Claude Opus 5 (1M context) --- .github/CONTRIBUTING.md | 6 +++++- .github/workflows/self-check-self-refs.yaml | 3 +++ README.md | 2 ++ 3 files changed, 10 insertions(+), 1 deletion(-) diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index a9d7bd7..8f33437 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -140,4 +140,8 @@ The workflows in this repository reference its own actions by commit SHA, exactl script/bump-self-refs --fix ``` -Commit the result and open a pull request. The check only compares a pin against the history of the action's own directory, so repointing the workflows does not itself make them stale again. +Commit the result and open a pull request. The check only compares a pin against the history of the action's own directory, so repointing the workflows does not itself make them stale again, even though merging the bump moves `main` to a new commit. + +Keep a bump in a pull request of its own. `--fix` can only pin to a commit that already exists, so a bump bundled with a change to an action pins to the commit before that change, and the check fails again as soon as it merges. Change the action first, then bump. + +The check runs on pushes to `main` rather than on pull requests, because while an action is being changed there is no commit yet to pin to. It reports after a merge, not before, so do not add `check_self_refs` to the repository's required status checks: it never reports on a pull request, and a pull request waiting on it would never be mergeable. diff --git a/.github/workflows/self-check-self-refs.yaml b/.github/workflows/self-check-self-refs.yaml index 062348b..548fcdb 100644 --- a/.github/workflows/self-check-self-refs.yaml +++ b/.github/workflows/self-check-self-refs.yaml @@ -11,6 +11,9 @@ concurrency: cancel-in-progress: true jobs: + # Deliberately not suffixed with `:required`. This job only runs after a merge, + # because while an action is being changed there is no commit yet to pin to, so + # it never reports on a pull request and cannot be a required status check. check_self_refs: name: check_self_refs runs-on: ubuntu-latest diff --git a/README.md b/README.md index 2218ad0..69cf748 100644 --- a/README.md +++ b/README.md @@ -80,6 +80,8 @@ The cost is that those pins do not move when an action changes, which leaves a w run: script/bump-self-refs ``` +Run that job on pushes to the default branch rather than on pull requests. While an action is being changed there is no commit yet to pin to, so the pin can only be moved in a follow-up change, which also means the job cannot serve as a required status check. + ## Managing Concurrency For workflows that run on pull requests, use the `concurrency` key to ensure that only one workflow runs at a time for a given pull request. This prevents multiple workflows from running simultaneously and potentially causing conflicts. From d5e44b0eb7135c5b239eb489aeca49156f449f52 Mon Sep 17 00:00:00 2001 From: Marco Walz Date: Thu, 17 Sep 2026 11:32:44 +0200 Subject: [PATCH 3/5] fix: fail clearly on a shallow clone in the self reference check Pins are resolved against local history, so a shallow checkout made every one of them report as an unknown commit, which reads as a problem with the references rather than with the checkout. The documented example now shows the checkout it needs, and says that the script covers this repository only. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 9 +++++++++ script/bump-self-refs | 14 ++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/README.md b/README.md index 69cf748..f96634a 100644 --- a/README.md +++ b/README.md @@ -76,12 +76,21 @@ A reusable workflow that other repositories call must reference the actions in i The cost is that those pins do not move when an action changes, which leaves a workflow running an older copy of an action than the one beside it in the tree. That failure is quiet: a newly added input handed to an action pinned from before it existed is reported as a warning, not an error, so the run stays green while the new behaviour does nothing. Pair the convention with a job that fails when a pin has fallen behind: ```yaml +- name: Checkout repository + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + # Each pin is resolved against local history, which the default shallow + # checkout does not have. + fetch-depth: 0 + - name: Check workflow self references run: script/bump-self-refs ``` Run that job on pushes to the default branch rather than on pull requests. While an action is being changed there is no commit yet to pin to, so the pin can only be moved in a follow-up change, which also means the job cannot serve as a required status check. +The script in this repository resolves its own pins against its own history, so it checks this repository only. A repository that consumes these workflows has the same problem with its own pins, but solving it means comparing against a different repository's releases, which is what Dependabot's `github-actions` ecosystem is for. + ## Managing Concurrency For workflows that run on pull requests, use the `concurrency` key to ensure that only one workflow runs at a time for a given pull request. This prevents multiple workflows from running simultaneously and potentially causing conflicts. diff --git a/script/bump-self-refs b/script/bump-self-refs index 8ed5fa9..4913b3c 100755 --- a/script/bump-self-refs +++ b/script/bump-self-refs @@ -36,6 +36,20 @@ fi cd "$(git rev-parse --show-toplevel)" +# Every pin is resolved against local history, which a shallow clone does not +# have. Without this the pins all report as unknown commits, which looks like a +# problem with the references rather than with the checkout. +if [ "$(git rev-parse --is-shallow-repository)" = 'true' ]; then + cat >&2 <<'EOF' +This repository is a shallow clone, so the commits the workflows pin cannot be +resolved. Fetch the full history first: + + git fetch --unshallow locally + actions/checkout fetch-depth 0 in a workflow +EOF + exit 2 +fi + # Built with a read loop rather than mapfile, which the bash 3.2 that ships with # macOS does not have. workflows=() From 7e72a023422942eef5566136b939bfa85a984d7e Mon Sep 17 00:00:00 2001 From: Marco Walz Date: Thu, 17 Sep 2026 12:21:47 +0200 Subject: [PATCH 4/5] fix: pin self references at the default branch, not HEAD --fix resolved HEAD, so running it from a branch wrote a SHA that squash and rebase merges discard, leaving a reference to a commit that never lands. It now pins at the default branch tip and says so when that differs from HEAD. Also corrects the usage text, which claimed --fix repoints every reference unconditionally when it only does so once one has fallen behind. Co-Authored-By: Claude Opus 5 (1M context) --- .github/CONTRIBUTING.md | 2 +- script/bump-self-refs | 29 +++++++++++++++++++++++------ 2 files changed, 24 insertions(+), 7 deletions(-) diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 8f33437..1d761cb 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -142,6 +142,6 @@ script/bump-self-refs --fix Commit the result and open a pull request. The check only compares a pin against the history of the action's own directory, so repointing the workflows does not itself make them stale again, even though merging the bump moves `main` to a new commit. -Keep a bump in a pull request of its own. `--fix` can only pin to a commit that already exists, so a bump bundled with a change to an action pins to the commit before that change, and the check fails again as soon as it merges. Change the action first, then bump. +Keep a bump in a pull request of its own. `--fix` pins at the tip of the default branch, which is the only commit guaranteed to stay reachable once a branch is squashed or rebased, so a bump bundled with a change to an action pins to the commit before that change and the check fails again as soon as it merges. Change the action first, then bump. The check runs on pushes to `main` rather than on pull requests, because while an action is being changed there is no commit yet to pin to. It reports after a merge, not before, so do not add `check_self_refs` to the repository's required status checks: it never reports on a pull request, and a pull request waiting on it would never be mergeable. diff --git a/script/bump-self-refs b/script/bump-self-refs index 4913b3c..abe4e0b 100755 --- a/script/bump-self-refs +++ b/script/bump-self-refs @@ -18,7 +18,7 @@ # Usage: # # script/bump-self-refs report stale references -# script/bump-self-refs --fix repoint every self reference at HEAD +# script/bump-self-refs --fix repoint them when at least one has fallen behind set -euo pipefail @@ -111,12 +111,29 @@ EOF exit 1 fi -# Repoint every self reference, not only the stale ones, so that one SHA -# describes the whole tree the way a consuming repository pins it. -REPO="$REPO" HEAD_SHA="$head_sha" perl -pi -e ' +# A pin has to name a commit that stays reachable. Squash and rebase merges +# rewrite a branch's commits, so the default branch is the only safe target; +# HEAD would leave a reference to a commit that never lands. +default_ref=$(git symbolic-ref -q --short refs/remotes/origin/HEAD || echo 'origin/main') +target_sha=$(git rev-parse --verify -q "${default_ref}^{commit}" || true) + +if [ -z "$target_sha" ]; then + echo "Cannot resolve ${default_ref}, so there is no safe commit to pin. Fetch it first." >&2 + exit 2 +fi + +if [ "$target_sha" != "$head_sha" ]; then + echo + echo "Pinning at ${default_ref} (${target_sha:0:8}) rather than HEAD, which is not on it." +fi + +# Once one pin has fallen behind, every self reference is repointed rather than +# only the stale ones, so that a single SHA describes the whole tree the way a +# consuming repository pins it. +REPO="$REPO" TARGET_SHA="$target_sha" perl -pi -e ' my $repo = quotemeta $ENV{REPO}; - s/($repo\/[A-Za-z0-9._\/-]+\@)[0-9a-f]{40}/$1$ENV{HEAD_SHA}/g; + s/($repo\/[A-Za-z0-9._\/-]+\@)[0-9a-f]{40}/$1$ENV{TARGET_SHA}/g; ' "${workflows[@]}" echo -echo "Repointed every $REPO self reference at ${head_sha:0:8}." +echo "Repointed every $REPO self reference at ${target_sha:0:8}." From d90e64cd6b0229690668afefdea79df15731f4a3 Mon Sep 17 00:00:00 2001 From: Marco Walz Date: Thu, 17 Sep 2026 15:08:04 +0200 Subject: [PATCH 5/5] chore: repoint workflow self references at the current commit #78 changed the generate-changelog and setup-commitizen actions, which the workflows still pinned from before, so they would have kept running the previous copies. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/check-commit-messages.yaml | 6 +++--- .github/workflows/check-pr-title.yaml | 6 +++--- .github/workflows/generate-changelog.yaml | 8 ++++---- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/.github/workflows/check-commit-messages.yaml b/.github/workflows/check-commit-messages.yaml index 90bc385..694b083 100644 --- a/.github/workflows/check-commit-messages.yaml +++ b/.github/workflows/check-commit-messages.yaml @@ -19,12 +19,12 @@ jobs: fetch-depth: 0 - name: Setup Python - uses: dfinity/ci-tools/actions/setup-python@4b4a84c58faa93db27c778a898e5a941948b3511 # main + uses: dfinity/ci-tools/actions/setup-python@059c18686a7318d8b67fb361bc2d939b16b3959f # main - name: Setup Commitizen - uses: dfinity/ci-tools/actions/setup-commitizen@4b4a84c58faa93db27c778a898e5a941948b3511 # main + uses: dfinity/ci-tools/actions/setup-commitizen@059c18686a7318d8b67fb361bc2d939b16b3959f # main - name: Check commit messages - uses: dfinity/ci-tools/actions/check-commit-messages@4b4a84c58faa93db27c778a898e5a941948b3511 # main + uses: dfinity/ci-tools/actions/check-commit-messages@059c18686a7318d8b67fb361bc2d939b16b3959f # main with: target_branch: ${{ inputs.target_branch }} diff --git a/.github/workflows/check-pr-title.yaml b/.github/workflows/check-pr-title.yaml index c038be3..25e9a47 100644 --- a/.github/workflows/check-pr-title.yaml +++ b/.github/workflows/check-pr-title.yaml @@ -8,10 +8,10 @@ jobs: runs-on: ubuntu-latest steps: - name: Setup Python - uses: dfinity/ci-tools/actions/setup-python@4b4a84c58faa93db27c778a898e5a941948b3511 # main + uses: dfinity/ci-tools/actions/setup-python@059c18686a7318d8b67fb361bc2d939b16b3959f # main - name: Setup Commitizen - uses: dfinity/ci-tools/actions/setup-commitizen@4b4a84c58faa93db27c778a898e5a941948b3511 # main + uses: dfinity/ci-tools/actions/setup-commitizen@059c18686a7318d8b67fb361bc2d939b16b3959f # main - name: Check pull request title - uses: dfinity/ci-tools/actions/check-pr-title@4b4a84c58faa93db27c778a898e5a941948b3511 # main + uses: dfinity/ci-tools/actions/check-pr-title@059c18686a7318d8b67fb361bc2d939b16b3959f # main diff --git a/.github/workflows/generate-changelog.yaml b/.github/workflows/generate-changelog.yaml index 5da7429..de2fec2 100644 --- a/.github/workflows/generate-changelog.yaml +++ b/.github/workflows/generate-changelog.yaml @@ -135,21 +135,21 @@ jobs: - name: Setup Python if: steps.release_commit.outputs.skip != 'true' - uses: dfinity/ci-tools/actions/setup-python@afeee4fbdc0683a88ec5a74ed7f59a2ce0e833ad # main + uses: dfinity/ci-tools/actions/setup-python@059c18686a7318d8b67fb361bc2d939b16b3959f # main - name: Setup Commitizen if: steps.release_commit.outputs.skip != 'true' - uses: dfinity/ci-tools/actions/setup-commitizen@afeee4fbdc0683a88ec5a74ed7f59a2ce0e833ad # main + uses: dfinity/ci-tools/actions/setup-commitizen@059c18686a7318d8b67fb361bc2d939b16b3959f # main - name: Generate changelog if: steps.release_commit.outputs.skip != 'true' - uses: dfinity/ci-tools/actions/generate-changelog@afeee4fbdc0683a88ec5a74ed7f59a2ce0e833ad # main + uses: dfinity/ci-tools/actions/generate-changelog@059c18686a7318d8b67fb361bc2d939b16b3959f # main with: file_name: ${{ inputs.file_name }} - name: Create pull request if: steps.release_commit.outputs.skip != 'true' - uses: dfinity/ci-tools/actions/create-pr@afeee4fbdc0683a88ec5a74ed7f59a2ce0e833ad # main + uses: dfinity/ci-tools/actions/create-pr@059c18686a7318d8b67fb361bc2d939b16b3959f # main with: branch_name: ${{ inputs.branch_name }} base_branch_name: ${{ inputs.base_branch_name }}