diff --git a/.github/workflows/validate.yaml b/.github/workflows/validate.yaml new file mode 100644 index 0000000..fc70850 --- /dev/null +++ b/.github/workflows/validate.yaml @@ -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 diff --git a/renovate.json b/renovate.json new file mode 100644 index 0000000..14c4a14 --- /dev/null +++ b/renovate.json @@ -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"] + } + ] +} diff --git a/scripts/check-actions.sh b/scripts/check-actions.sh new file mode 100755 index 0000000..688411a --- /dev/null +++ b/scripts/check-actions.sh @@ -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" diff --git a/testdata/selftest/go/add.go b/testdata/selftest/go/add.go new file mode 100644 index 0000000..dd709e9 --- /dev/null +++ b/testdata/selftest/go/add.go @@ -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 } diff --git a/testdata/selftest/go/add_test.go b/testdata/selftest/go/add_test.go new file mode 100644 index 0000000..fa38b71 --- /dev/null +++ b/testdata/selftest/go/add_test.go @@ -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) + } +} diff --git a/testdata/selftest/go/go.mod b/testdata/selftest/go/go.mod new file mode 100644 index 0000000..ba3bdc8 --- /dev/null +++ b/testdata/selftest/go/go.mod @@ -0,0 +1,3 @@ +module example.com/selftest + +go 1.24 diff --git a/testdata/selftest/node/package-lock.json b/testdata/selftest/node/package-lock.json new file mode 100644 index 0000000..3ec29e7 --- /dev/null +++ b/testdata/selftest/node/package-lock.json @@ -0,0 +1,12 @@ +{ + "name": "selftest", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "selftest", + "version": "0.0.0" + } + } +} diff --git a/testdata/selftest/node/package.json b/testdata/selftest/node/package.json new file mode 100644 index 0000000..8ed9b68 --- /dev/null +++ b/testdata/selftest/node/package.json @@ -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')\"" + } +} diff --git a/testdata/selftest/node/selftest.test.js b/testdata/selftest/node/selftest.test.js new file mode 100644 index 0000000..2ef81c9 --- /dev/null +++ b/testdata/selftest/node/selftest.test.js @@ -0,0 +1,6 @@ +const test = require('node:test'); +const assert = require('node:assert'); + +test('addition', () => { + assert.strictEqual(2 + 3, 5); +});