Repository navigation
chore(ci): the relock push needs workflows: write, not just contents:… #9
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # This workflow is managed by gh actions-lock. | ||
| # SPDX-License-Identifier: MPL-2.0 | ||
| # | ||
| # actions-lock.yml — keep .github/workflows/actions.lock in step with the | ||
| # workflows themselves. | ||
| # | ||
| # Why this exists: an out-of-date lockfile is not a soft failure. Every | ||
| # workflow listed in actions.lock dies with `startup_failure` before a single | ||
| # step runs, so it produces no check runs at all — which is how | ||
| # game-server-admin#103 took out both required-check producers (`ABI Contract` | ||
| # and `Cross-Platform Build & Test`) in one commit. Dependabot bumps action | ||
| # tags weekly and cannot run `gh actions-lock`, so without a gate here the next | ||
| # group bump re-breaks the whole CI estate, silently. | ||
| # | ||
| # The lockfile is not decoration for another reason: this repo's Actions policy | ||
| # requires every action to be pinned to a full-length commit SHA, and a tag ref | ||
| # such as actions/checkout@v7.0.1 is accepted *only because the lockfile pins | ||
| # it*. Delete the lockfile and even actions/checkout is refused. | ||
| # | ||
| # The gate has two halves: | ||
| # verify — read-only (`gh actions-lock --verify`). Fails when the lockfile | ||
| # and the workflows disagree, so a bump cannot merge unrelocked. | ||
| # relock — runs when verify asked it to. Regenerates the lockfile and | ||
| # delivers it: pushed straight back to the branch on a pull | ||
| # request, or opened as a PR when the target is the default branch | ||
| # (main requires signed commits, so a bot cannot push there). | ||
| # | ||
| # A push made with GITHUB_TOKEN does not start a new workflow run, so the | ||
| # relock commit cannot loop. | ||
| name: Actions Lockfile | ||
| on: | ||
| pull_request: | ||
| push: | ||
| branches: [main, master] | ||
| workflow_dispatch: | ||
| inputs: | ||
| mode: | ||
| description: 'verify = report only; relock = regenerate and deliver' | ||
| required: false | ||
| default: 'verify' | ||
| type: choice | ||
| options: | ||
| - verify | ||
| - relock | ||
| permissions: | ||
| contents: write | ||
| # actions.lock lives under .github/workflows/, and GitHub treats every path | ||
| # there as workflow-governed: pushing a change to it with GITHUB_TOKEN and | ||
| # only `contents: write` is rejected outright — | ||
| # ! [remote rejected] (refusing to allow a GitHub App to create or update | ||
| # workflow `.github/workflows/actions.lock` without `workflows` permission) | ||
| workflows: write | ||
| concurrency: | ||
| group: actions-lockfile-${{ github.ref }} | ||
| cancel-in-progress: false | ||
| env: | ||
| # Bump deliberately: the lockfile format is pre-1.0 and its shape can | ||
| # change between releases. | ||
| ACTIONS_LOCK_EXTENSION_VERSION: v0.1.7-rc.1 | ||
| jobs: | ||
| verify: | ||
| name: Verify actions.lock | ||
| runs-on: ubuntu-latest | ||
| timeout-minutes: 15 | ||
| outputs: | ||
| # `relock` is computed in the shell, not in an `if:` expression: | ||
| # `inputs.mode` is not a recognised named value outside | ||
| # workflow_dispatch, and referencing it there is a start-up error. | ||
| in_sync: ${{ steps.check.outputs.in_sync }} | ||
| relock: ${{ steps.check.outputs.relock }} | ||
| steps: | ||
| - name: Checkout | ||
| id: checkout | ||
| uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 | ||
| - name: Install gh actions-lock | ||
| id: install | ||
| run: | | ||
| set -euo pipefail | ||
| gh extension install github/gh-actions-lock --pin "$ACTIONS_LOCK_EXTENSION_VERSION" | ||
| gh actions-lock --help | ||
| - name: Check the lockfile matches the workflows | ||
| id: check | ||
| env: | ||
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | ||
| EVENT_NAME: ${{ github.event_name }} | ||
| DISPATCH_MODE: ${{ github.event.inputs.mode }} | ||
| run: | | ||
| # The runner's default shell is `bash -e {0}`, and `set -uo pipefail` | ||
| # does NOT clear errexit — so every step that inspects an exit code | ||
| # has to switch it off explicitly, or a failing command aborts the | ||
| # script before the handler below it can report anything. | ||
| set -uo pipefail | ||
| set +e | ||
| code=0 | ||
| gh actions-lock --verify --no-interactive \ | ||
| --json=valid,findings >actions-lock-report.json 2>actions-lock-report.err \ | ||
| || code=$? | ||
| echo "gh actions-lock --verify exited ${code}" | tee -a "$GITHUB_STEP_SUMMARY" | ||
| if [ "$code" -eq 0 ]; then | ||
| echo "in_sync=true" >> "$GITHUB_OUTPUT" | ||
| # An explicit relock dispatch still wins even when the file looks | ||
| # fine: it is how a maintainer refreshes pins that have moved. | ||
| if [ "$EVENT_NAME" = "workflow_dispatch" ] && [ "$DISPATCH_MODE" = "relock" ]; then | ||
| echo "relock=true" >> "$GITHUB_OUTPUT" | ||
| else | ||
| echo "relock=false" >> "$GITHUB_OUTPUT" | ||
| fi | ||
| echo "actions.lock agrees with every workflow." >> "$GITHUB_STEP_SUMMARY" | ||
| exit 0 | ||
| fi | ||
| if [ "$code" -ne 1 ]; then | ||
| # 1 = blocking findings (out of sync). Anything else is the tool | ||
| # failing, not the repo: do not let the relock job paper over it. | ||
| # Re-emit the tool's stderr as annotations — a bare exit code on a | ||
| # runner nobody can read logs from is worse than a verbose diff. | ||
| echo "in_sync=unknown" >> "$GITHUB_OUTPUT" | ||
| echo "relock=false" >> "$GITHUB_OUTPUT" | ||
| echo "::error::gh actions-lock --verify exited ${code} (tool failure, not a lockfile mismatch)" | ||
| while IFS= read -r line; do | ||
| if [ -n "$line" ]; then echo "::error::[gh actions-lock] ${line}"; fi | ||
| done < <(tail -n 30 actions-lock-report.err | tr -d '\r' | cut -c1-200) | ||
| exit "$code" | ||
| fi | ||
| echo "in_sync=false" >> "$GITHUB_OUTPUT" | ||
| echo "relock=true" >> "$GITHUB_OUTPUT" | ||
| echo "::error::actions.lock is out of step with .github/workflows — every onboarded workflow will fail with startup_failure until it is regenerated" | ||
| { | ||
| echo '## actions.lock is out of sync' | ||
| echo | ||
| echo '```json' | ||
| jq . actions-lock-report.json 2>/dev/null || cat actions-lock-report.json | ||
| echo '```' | ||
| echo | ||
| echo 'Fix locally with `gh actions-lock`, or run this workflow in `relock` mode.' | ||
| } >> "$GITHUB_STEP_SUMMARY" | ||
| exit 1 | ||
| relock: | ||
| name: Regenerate actions.lock | ||
| needs: verify | ||
| if: always() && needs.verify.outputs.relock == 'true' | ||
| runs-on: ubuntu-latest | ||
| timeout-minutes: 20 | ||
| steps: | ||
| - name: Decide where the fix goes | ||
| id: target | ||
| env: | ||
| EVENT_NAME: ${{ github.event_name }} | ||
| HEAD_REF: ${{ github.event.pull_request.head.ref }} | ||
| HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }} | ||
| REPO: ${{ github.repository }} | ||
| REF: ${{ github.ref }} | ||
| DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} | ||
| run: | | ||
| set -euo pipefail | ||
| if [ "$EVENT_NAME" = "pull_request" ]; then | ||
| if [ "$HEAD_REPO" != "$REPO" ]; then | ||
| echo "skipped=true" >> "$GITHUB_OUTPUT" | ||
| echo "::warning::Fork pull request: cannot push the regenerated lockfile back to ${HEAD_REPO}. Run \`gh actions-lock\` locally." | ||
| exit 0 | ||
| fi | ||
| echo "skipped=false" >> "$GITHUB_OUTPUT" | ||
| echo "branch=${HEAD_REF}" >> "$GITHUB_OUTPUT" | ||
| echo "on_default=false" >> "$GITHUB_OUTPUT" | ||
| else | ||
| branch="${REF#refs/heads/}" | ||
| echo "skipped=false" >> "$GITHUB_OUTPUT" | ||
| echo "branch=${branch}" >> "$GITHUB_OUTPUT" | ||
| if [ "$branch" = "$DEFAULT_BRANCH" ]; then | ||
| # main requires signed commits, so a bot commit cannot land | ||
| # there: deliver the fix as a pull request instead. | ||
| echo "on_default=true" >> "$GITHUB_OUTPUT" | ||
| else | ||
| echo "on_default=false" >> "$GITHUB_OUTPUT" | ||
| fi | ||
| fi | ||
| - name: Checkout the branch that needs the lockfile | ||
| id: checkout-lock | ||
| if: steps.target.outputs.skipped == 'false' | ||
| uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 | ||
| with: | ||
| ref: ${{ steps.target.outputs.branch }} | ||
| - name: Install gh actions-lock | ||
| id: install | ||
| if: steps.target.outputs.skipped == 'false' | ||
| run: | | ||
| set -euo pipefail | ||
| gh extension install github/gh-actions-lock --pin "$ACTIONS_LOCK_EXTENSION_VERSION" | ||
| - name: Regenerate the lockfile | ||
| id: regenerate | ||
| if: steps.target.outputs.skipped == 'false' | ||
| env: | ||
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | ||
| run: | | ||
| set -uo pipefail | ||
| set +e | ||
| code=0 | ||
| gh actions-lock --relock --no-interactive >relock.out 2>relock.err || code=$? | ||
| cat relock.out || true | ||
| cat relock.err || true | ||
| # A non-zero exit here does NOT mean nothing was written. The tool | ||
| # writes the lockfile and then reports whatever it still objects to | ||
| # (a bare SHA with no symbolic ref, say) — findings that do not | ||
| # invalidate the file. Only "the file did not change" is a real | ||
| # failure, and that is checked when the change is delivered. | ||
| if [ "$code" -ne 0 ]; then | ||
| echo "::warning::gh actions-lock --relock exited ${code}; the regenerated lockfile is still delivered if it changed" | ||
| while IFS= read -r line; do | ||
| if [ -n "$line" ]; then echo "::warning::[gh actions-lock] ${line}"; fi | ||
| done < <(tail -n 30 relock.err | tr -d '\r' | cut -c1-200) | ||
| fi | ||
| git --no-pager diff --stat -- .github/workflows/actions.lock || true | ||
| git --no-pager diff -- .github/workflows/actions.lock | head -n 60 || true | ||
| - name: Push the regenerated lockfile back to the branch | ||
| id: deliver | ||
| if: steps.target.outputs.skipped == 'false' && steps.target.outputs.on_default == 'false' | ||
| run: | | ||
| set -uo pipefail | ||
| set +e | ||
| git config user.name 'github-actions[bot]' | ||
| git config user.email '41898282+github-actions[bot]@users.noreply.github.com' | ||
| git add .github/workflows/actions.lock | ||
| if git diff --cached --quiet; then | ||
| echo "::error::gh actions-lock produced no change to actions.lock — the pull request stays unstartable and the file must be regenerated by hand" | ||
| exit 1 | ||
| fi | ||
| code=0 | ||
| git commit -m "chore(ci): regenerate actions.lock (gh actions-lock --relock) | ||
| The lockfile and the workflows had drifted apart. While they do, | ||
| every onboarded workflow ends in startup_failure and produces no | ||
| check runs at all. See game-server-admin#103." >commit.out 2>&1 \ | ||
| || code=$? | ||
| if [ "$code" -ne 0 ]; then | ||
| cat commit.out || true | ||
| while IFS= read -r line; do | ||
| if [ -n "$line" ]; then echo "::error::[git commit] ${line}"; fi | ||
| done < <(tail -n 20 commit.out | tr -d '\r' | cut -c1-200) | ||
| exit 1 | ||
| fi | ||
| code=0 | ||
| git push origin "HEAD:refs/heads/${{ steps.target.outputs.branch }}" >push.out 2>&1 \ | ||
| || code=$? | ||
| if [ "$code" -ne 0 ]; then | ||
| cat push.out || true | ||
| while IFS= read -r line; do | ||
| if [ -n "$line" ]; then echo "::error::[git push] ${line}"; fi | ||
| done < <(tail -n 20 push.out | tr -d '\r' | cut -c1-200) | ||
| exit 1 | ||
| fi | ||
| echo "::notice::Regenerated actions.lock pushed to ${{ steps.target.outputs.branch }}; this pull request will re-verify on the next run." | ||
| - name: Open a pull request with the regenerated lockfile | ||
| id: propose | ||
| if: steps.target.outputs.skipped == 'false' && steps.target.outputs.on_default == 'true' | ||
| env: | ||
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | ||
| BASE_BRANCH: ${{ steps.target.outputs.branch }} | ||
| run: | | ||
| set -uo pipefail | ||
| set +e | ||
| git config user.name 'github-actions[bot]' | ||
| git config user.email '41898282+github-actions[bot]@users.noreply.github.com' | ||
| branch="ci/actions-lock-relock-${GITHUB_RUN_ID}" | ||
| git switch --create "$branch" | ||
| git add .github/workflows/actions.lock | ||
| if git diff --cached --quiet; then | ||
| echo "::error::gh actions-lock produced no change to actions.lock — nothing to propose" | ||
| exit 1 | ||
| fi | ||
| git commit -m "chore(ci): regenerate actions.lock (gh actions-lock --relock) | ||
| The lockfile and the workflows had drifted apart. While they do, | ||
| every onboarded workflow ends in startup_failure and produces no | ||
| check runs at all. See game-server-admin#103." | ||
| git push --set-upstream origin "$branch" | ||
| gh pr create \ | ||
| --base "$BASE_BRANCH" \ | ||
| --head "$branch" \ | ||
| --title 'chore(ci): regenerate actions.lock' \ | ||
| --body '`gh actions-lock --verify` failed on `'"$BASE_BRANCH"'` because `actions.lock` no longer matches the workflows. While they disagree, every onboarded workflow ends in `startup_failure` and produces no check runs at all. | ||
| This PR is the output of `gh actions-lock --relock`. See game-server-admin#103.' | ||
| - name: Diagnostics | ||
| if: always() | ||
| run: | | ||
| set -uo pipefail | ||
| set +e | ||
| echo "--- git state ---" | ||
| git --no-pager log --oneline -1 || true | ||
| git status --short || true | ||
| echo "--- actions.lock ---" | ||
| ls -la .github/workflows/actions.lock || true | ||
| outcomes="target=${{ steps.target.outcome }} checkout=${{ steps.checkout-lock.outcome }} install=${{ steps.install.outcome }} regenerate=${{ steps.regenerate.outcome }} deliver=${{ steps.deliver.outcome }} propose=${{ steps.propose.outcome }}" | ||
| echo "::notice::relock outcomes — ${outcomes}" | ||
| { | ||
| echo '## Regenerate actions.lock — step outcomes' | ||
| echo | ||
| echo '```' | ||
| echo "${outcomes}" | ||
| echo '```' | ||
| } >> "$GITHUB_STEP_SUMMARY" | ||
| # Surface anything the tool left behind; a step that failed before | ||
| # printing it would otherwise be undiagnosable. | ||
| for f in relock.out relock.err; do | ||
| if [ -s "$f" ]; then | ||
| echo "--- ${f} ---" | ||
| tail -n 40 "$f" | cut -c1-200 | ||
| fi | ||
| done || true | ||