diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 3302153..1d761cb 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -129,3 +129,19 @@ 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, even though merging the bump moves `main` to a new commit. + +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/.github/workflows/check-commit-messages.yaml b/.github/workflows/check-commit-messages.yaml index 4288f45..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@afeee4fbdc0683a88ec5a74ed7f59a2ce0e833ad # main + uses: dfinity/ci-tools/actions/setup-python@059c18686a7318d8b67fb361bc2d939b16b3959f # main - name: Setup Commitizen - uses: dfinity/ci-tools/actions/setup-commitizen@afeee4fbdc0683a88ec5a74ed7f59a2ce0e833ad # main + uses: dfinity/ci-tools/actions/setup-commitizen@059c18686a7318d8b67fb361bc2d939b16b3959f # main - name: Check commit messages - uses: dfinity/ci-tools/actions/check-commit-messages@afeee4fbdc0683a88ec5a74ed7f59a2ce0e833ad # 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 cf2dd09..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@afeee4fbdc0683a88ec5a74ed7f59a2ce0e833ad # main + uses: dfinity/ci-tools/actions/setup-python@059c18686a7318d8b67fb361bc2d939b16b3959f # main - name: Setup Commitizen - uses: dfinity/ci-tools/actions/setup-commitizen@afeee4fbdc0683a88ec5a74ed7f59a2ce0e833ad # main + uses: dfinity/ci-tools/actions/setup-commitizen@059c18686a7318d8b67fb361bc2d939b16b3959f # main - name: Check pull request title - uses: dfinity/ci-tools/actions/check-pr-title@afeee4fbdc0683a88ec5a74ed7f59a2ce0e833ad # 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 }} diff --git a/.github/workflows/self-check-self-refs.yaml b/.github/workflows/self-check-self-refs.yaml new file mode 100644 index 0000000..548fcdb --- /dev/null +++ b/.github/workflows/self-check-self-refs.yaml @@ -0,0 +1,29 @@ +name: Check Self References + +on: + push: + branches: + - main + workflow_dispatch: + +concurrency: + group: main-${{ github.workflow }} + 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 + 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..f96634a 100644 --- a/README.md +++ b/README.md @@ -69,6 +69,28 @@ 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: 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 new file mode 100755 index 0000000..abe4e0b --- /dev/null +++ b/script/bump-self-refs @@ -0,0 +1,139 @@ +#!/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 them when at least one has fallen behind + +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)" + +# 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=() +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 <&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{TARGET_SHA}/g; +' "${workflows[@]}" + +echo +echo "Repointed every $REPO self reference at ${target_sha:0:8}."