Skip to content

chore(ci): the relock push needs workflows: write, not just contents:… #9

chore(ci): the relock push needs workflows: write, not just contents:…

chore(ci): the relock push needs workflows: write, not just contents:… #9

Workflow file for this run

# This workflow is managed by gh actions-lock.

Check failure on line 1 in .github/workflows/actions-lock.yml

View workflow run for this annotation

GitHub Actions / .github/workflows/actions-lock.yml

Invalid workflow file

(Line: 55, Col: 3): Unexpected value 'workflows'
# 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