feat: validate workflows and actions, and prepare Renovate bumps

scripts/check-actions.sh runs actionlint and checks that every external
uses: reference resolves. The validate workflow runs it, then runs the
go-test and node-build actions and the node-pr workflow against small
fixtures, so a bump of an action they use is exercised before it merges.

renovate.json bumps the actions this repository uses. Minor and patch
bumps merge themselves once validate is green. Major bumps wait for a
person, because every consumer calls these actions at @main. The same
script runs as a Renovate post-upgrade task and marks a failing branch so
no PR opens.
This commit is contained in:
2026-10-03 22:22:22 +01:00
parent 2e49670452
commit 1aece66c7f
9 changed files with 288 additions and 0 deletions
+91
View File
@@ -0,0 +1,91 @@
name: Validate
# Checks that a change to the workflows or composite actions still works. Renovate
# opens a PR for each action bump; this workflow is the gate those PRs must pass.
# The aggregate job "validate" is the one required status check.
on:
pull_request:
paths:
- ".github/**"
- "scripts/**"
- "testdata/**"
workflow_dispatch:
permissions:
contents: read
pull-requests: read
concurrency:
group: validate-${{ github.head_ref || github.ref_name }}
cancel-in-progress: true
jobs:
static:
name: Static checks
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Checkout
uses: actions/checkout@v6
# actionlint, then every external `uses:` reference must resolve.
- name: Check workflows and action references
run: scripts/check-actions.sh --ci
selftest-go:
name: Self-test go-test action
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Checkout
uses: actions/checkout@v6
# The local path runs the action as it is on this branch, not at @main.
- name: Run the go-test action on the fixture module
uses: ./.github/actions/go-test
with:
working-directory: testdata/selftest/go
selftest-node-build:
name: Self-test node-build action
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Run the node-build action on the fixture project
uses: ./.github/actions/node-build
with:
build-script: cd testdata/selftest/node && npm ci && npm run build
cache-dependency-path: testdata/selftest/node/package-lock.json
selftest-node-pr:
name: Self-test node-pr workflow
uses: ./.github/workflows/node-pr.yaml
permissions:
contents: read
pull-requests: read
with:
working-directory: testdata/selftest/node
require-tests: true
validate:
name: validate
if: always()
needs: [static, selftest-go, selftest-node-build, selftest-node-pr]
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Require every check to pass
env:
RESULTS: ${{ join(needs.*.result, ' ') }}
run: |
echo "results: $RESULTS"
for r in $RESULTS; do
if [ "$r" != success ]; then
echo "::error::a validation job finished as '$r'"
exit 1
fi
done
+37
View File
@@ -0,0 +1,37 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"timezone": "Europe/London",
"enabledManagers": ["github-actions"],
"semanticCommits": "enabled",
"semanticCommitType": "chore",
"semanticCommitScope": "deps",
"dependencyDashboard": false,
"prConcurrentLimit": 0,
"prHourlyLimit": 0,
"minimumReleaseAge": "3 days",
"postUpgradeTasks": {
"commands": ["scripts/check-actions.sh --renovate"],
"fileFilters": [".validation-failed"],
"executionMode": "branch"
},
"packageRules": [
{
"description": "This repository's own composite actions are tracked at @main by design.",
"matchPackageNames": ["lukaszraczylo/shared-actions"],
"enabled": false
},
{
"description": "Minor and patch bumps merge on their own once the validate check is green.",
"matchUpdateTypes": ["minor", "patch", "digest", "pin"],
"automerge": true,
"automergeType": "pr",
"platformAutomerge": true
},
{
"description": "Every consumer calls these actions at @main, so a major bump waits for a human.",
"matchUpdateTypes": ["major"],
"automerge": false,
"addLabels": ["major"]
}
]
}
+115
View File
@@ -0,0 +1,115 @@
#!/usr/bin/env bash
# Static checks for the workflows and composite actions in this repository:
# 1. actionlint (schema, expressions, and shell steps through shellcheck when present)
# 2. every external `uses:` reference resolves to a real tag or branch
#
# Usage: scripts/check-actions.sh [--ci | --renovate]
# --ci exit 1 when a check fails (default)
# --renovate exit 0 and write .validation-failed when a check fails, so the
# Renovate runner deletes the branch before a PR can open
#
# Needs: bash, git, curl, tar, sha256sum or shasum. No other tools: actionlint is
# downloaded at a pinned version and checked against a pinned SHA-256.
set -u
mode=--ci
case "${1:-}" in
"" | --ci) mode=--ci ;;
--renovate) mode=--renovate ;;
*) echo "usage: $0 [--ci | --renovate]" >&2; exit 2 ;;
esac
ACTIONLINT_VERSION=1.7.12
ACTIONLINT_SHA256_AMD64=8aca8db96f1b94770f1b0d72b6dddcb1ebb8123cb3712530b08cc387b349a3d8
ACTIONLINT_SHA256_ARM64=325e971b6ba9bfa504672e29be93c24981eeb1c07576d730e9f7c8805afff0c6
marker=.validation-failed
cd "$(git rev-parse --show-toplevel)" || exit 2
fail=0
problem() {
echo "::error::$*" >&2
fail=1
}
sha256_of() {
if command -v sha256sum >/dev/null 2>&1; then sha256sum "$1" | cut -d' ' -f1; else shasum -a 256 "$1" | cut -d' ' -f1; fi
}
install_actionlint() {
local os arch want url tmp
case "$(uname -s)" in Linux) os=linux ;; Darwin) os=darwin ;; *) return 1 ;; esac
case "$(uname -m)" in
x86_64 | amd64) arch=amd64; want=$ACTIONLINT_SHA256_AMD64 ;;
aarch64 | arm64) arch=arm64; want=$ACTIONLINT_SHA256_ARM64 ;;
*) return 1 ;;
esac
# The pinned checksums are for the linux builds only.
[ "$os" = linux ] || return 1
tmp=$(mktemp -d)
url="https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/actionlint_${ACTIONLINT_VERSION}_${os}_${arch}.tar.gz"
curl -fsSL --retry 3 -o "$tmp/a.tgz" "$url" || return 1
[ "$(sha256_of "$tmp/a.tgz")" = "$want" ] || { echo "actionlint checksum mismatch" >&2; return 1; }
tar -xzf "$tmp/a.tgz" -C "$tmp" actionlint || return 1
echo "$tmp/actionlint"
}
run_actionlint() {
local bin
if command -v actionlint >/dev/null 2>&1; then
bin=$(command -v actionlint)
else
bin=$(install_actionlint) || { problem "cannot install actionlint ${ACTIONLINT_VERSION}"; return; }
fi
# -shellcheck= disables the shellcheck pass when the binary is missing.
local opts=()
command -v shellcheck >/dev/null 2>&1 || opts+=(-shellcheck=)
# Info and style findings from shellcheck (for example SC2086 on intentional word
# splitting) do not fail the gate. Warnings and errors do. Composite actions are
# not linted by actionlint 1.7; workflows are.
opts+=(-ignore 'shellcheck reported issue.+SC[0-9]+:(info|style):')
if ! "$bin" "${opts[@]}" -color .github/workflows/*.y*ml; then
problem "actionlint reported problems"
fi
}
# Resolves every `uses: owner/repo[/path]@ref` that points outside this repository.
check_refs() {
local files uses ref repo path checked=""
files=$(find .github -type f \( -name '*.yml' -o -name '*.yaml' \))
uses=$(grep -hoE '^[[:space:]]*(-[[:space:]]+)?uses:[[:space:]]*[^[:space:]#]+' $files | sed -E 's/^[[:space:]]*(-[[:space:]]+)?uses:[[:space:]]*//' | sort -u)
while IFS= read -r u; do
[ -n "$u" ] || continue
case "$u" in ./* | docker://*) continue ;; esac
repo=${u%%@*}
ref=${u#*@}
[ "$repo" != "$u" ] || { problem "no @ref in 'uses: $u'"; continue; }
# owner/repo/sub/path -> owner/repo
path=$(echo "$repo" | cut -d/ -f1,2)
# This repository's own composite actions are tracked at @main by design.
[ "$path" = lukaszraczylo/shared-actions ] && continue
case " $checked " in *" $path@$ref "*) continue ;; esac
checked="$checked $path@$ref"
if ! git ls-remote --exit-code "https://github.com/$path" "refs/tags/$ref" "refs/heads/$ref" >/dev/null 2>&1; then
# A full commit SHA is not listed by ls-remote; accept it when the repository answers at all.
if echo "$ref" | grep -qE '^[0-9a-f]{40}$' && git ls-remote --exit-code "https://github.com/$path" HEAD >/dev/null 2>&1; then
continue
fi
problem "'uses: $u' does not resolve: no tag or branch $ref in $path"
fi
done <<<"$uses"
}
rm -f "$marker"
run_actionlint
check_refs
if [ "$fail" -ne 0 ]; then
if [ "$mode" = --renovate ]; then
echo "validation failed; writing $marker" >&2
printf 'actions validation failed\n' >"$marker"
exit 0
fi
exit 1
fi
echo "actions validation passed"
+5
View File
@@ -0,0 +1,5 @@
// Package selftest is a fixture that the validate workflow runs the go-test action against.
package selftest
// Add returns the sum of a and b.
func Add(a, b int) int { return a + b }
+9
View File
@@ -0,0 +1,9 @@
package selftest
import "testing"
func TestAdd(t *testing.T) {
if got := Add(2, 3); got != 5 {
t.Fatalf("Add(2, 3) = %d, want 5", got)
}
}
+3
View File
@@ -0,0 +1,3 @@
module example.com/selftest
go 1.24
+12
View File
@@ -0,0 +1,12 @@
{
"name": "selftest",
"version": "0.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "selftest",
"version": "0.0.0"
}
}
}
+10
View File
@@ -0,0 +1,10 @@
{
"name": "selftest",
"version": "0.0.0",
"private": true,
"scripts": {
"lint": "node -e \"process.exit(0)\"",
"test": "node --test",
"build": "node -e \"console.log('built')\""
}
}
+6
View File
@@ -0,0 +1,6 @@
const test = require('node:test');
const assert = require('node:assert');
test('addition', () => {
assert.strictEqual(2 + 3, 5);
});