From 89d735e737f304d007bc9369047959ff0b4cf26d Mon Sep 17 00:00:00 2001 From: kurok <22548029+kurok@users.noreply.github.com> Date: Thu, 2 Jul 2026 20:08:54 +0100 Subject: [PATCH] feat: automate actions/runner version bumps (bump script + scheduled PR workflow) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumping the default actions/runner version was a recurring, deadline-driven, multi-file manual chore (copy checksums, edit runner-checksums.js, action.yml, config, tests, docs, remember npm ci before npm run package). Mechanical + recurring = automate it. - scripts/bump-runner.js codifies the whole recipe in one command: parseChecksums (reads the x64/arm64 SHA-256 from the release body, failing loudly on format drift), applyBump (adds the checksum entries and bumps the default version across action.yml, config, README, and tests — idempotent), then npm ci && npm run package (the gotcha, encoded not documented). Also the manual path: node scripts/bump-runner.js . - .github/workflows/bump-runner.yml: weekly + manual dispatch; detects a newer release, runs the script, and opens a conventional-commit PR citing the release notes and checksum provenance. No auto-merge (removes toil, not judgment). Minimal permissions (contents/pull-requests: write), SHA-pinned actions, GITHUB_TOKEN only. No-op when already latest; skips if the bump branch already exists. - arm64 checksums handled when present (forward-compatible with #43); single-arch today is unaffected. Tests: parseChecksums against a real-format release-body fixture (whitespace tolerance, single-arch, format-drift), applyBump against a temp copy of the real repo files + idempotency (166 tests total). README "Updating the pinned runner version" section replaces the manual recipe; runner-checksums.js maintenance comment points to the automation. Closes #47 Signed-off-by: kurok <22548029+kurok@users.noreply.github.com> --- .github/workflows/bump-runner.yml | 96 +++++++++++++++ README.md | 7 ++ dist/index.js | 16 ++- scripts/bump-runner.js | 154 +++++++++++++++++++++++++ src/runner-checksums.js | 16 ++- tests/bump-runner.test.js | 93 +++++++++++++++ tests/fixtures/runner-release-body.txt | 12 ++ 7 files changed, 382 insertions(+), 12 deletions(-) create mode 100644 .github/workflows/bump-runner.yml create mode 100644 scripts/bump-runner.js create mode 100644 tests/bump-runner.test.js create mode 100644 tests/fixtures/runner-release-body.txt diff --git a/.github/workflows/bump-runner.yml b/.github/workflows/bump-runner.yml new file mode 100644 index 00000000..7903d012 --- /dev/null +++ b/.github/workflows/bump-runner.yml @@ -0,0 +1,96 @@ +name: Bump actions/runner + +# Opens a PR bumping the pinned default actions/runner version when a newer +# release is available. Toil elimination only — no auto-merge; a human +# reviews (runner updates occasionally break things) and the PR body cites +# the checksum provenance so review is a link-click. +on: + schedule: + - cron: '17 6 * * 1' # weekly, Monday 06:17 UTC + workflow_dispatch: + inputs: + version: + description: 'Runner version to bump to (default: latest release)' + required: false + +permissions: + contents: write + pull-requests: write + +jobs: + bump: + name: Bump runner version + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - name: Set up Node.js + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: 24 + cache: npm + + - name: Resolve target and current versions + id: versions + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + INPUT_VERSION: ${{ github.event.inputs.version }} + run: | + if [ -n "$INPUT_VERSION" ]; then + target="$INPUT_VERSION" + else + target=$(gh api repos/actions/runner/releases/latest --jq .tag_name | sed 's/^v//') + fi + current=$(node -e "console.log(require('./scripts/bump-runner').readCurrentVersion('.'))") + echo "target=$target" >> "$GITHUB_OUTPUT" + echo "current=$current" >> "$GITHUB_OUTPUT" + echo "Current: $current Target: $target" + if [ "$target" = "$current" ]; then + echo "up_to_date=true" >> "$GITHUB_OUTPUT" + else + echo "up_to_date=false" >> "$GITHUB_OUTPUT" + fi + + - name: Check for an existing bump branch/PR + id: existing + if: steps.versions.outputs.up_to_date == 'false' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + branch="bot/bump-runner-${{ steps.versions.outputs.target }}" + echo "branch=$branch" >> "$GITHUB_OUTPUT" + if git ls-remote --exit-code --heads origin "refs/heads/$branch" >/dev/null 2>&1; then + echo "exists=true" >> "$GITHUB_OUTPUT" + echo "Branch $branch already exists — skipping." + else + echo "exists=false" >> "$GITHUB_OUTPUT" + fi + + - name: Apply bump and rebuild dist + if: steps.versions.outputs.up_to_date == 'false' && steps.existing.outputs.exists == 'false' + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: node scripts/bump-runner.js "${{ steps.versions.outputs.target }}" + + - name: Open pull request + if: steps.versions.outputs.up_to_date == 'false' && steps.existing.outputs.exists == 'false' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TARGET: ${{ steps.versions.outputs.target }} + CURRENT: ${{ steps.versions.outputs.current }} + BRANCH: ${{ steps.existing.outputs.branch }} + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git checkout -b "$BRANCH" + git add -A + git commit -m "fix: bump default actions/runner to $TARGET" + git push origin "$BRANCH" + # Single-quoted printf format so backticks stay literal (no command + # substitution); %s are filled from the args. + body=$(printf 'Automated bump of the default `actions/runner` from `%s` to `%s`.\n\n- Release notes: https://github.com/actions/runner/releases/tag/v%s\n- Checksums are taken from that release body and verified by the `verify-runner-url` CI job.\n\nGenerated by `.github/workflows/bump-runner.yml` via `scripts/bump-runner.js`. Review and merge — no auto-merge.' "$CURRENT" "$TARGET" "$TARGET") + gh pr create \ + --base main \ + --head "$BRANCH" \ + --title "fix: bump default actions/runner to $TARGET" \ + --body "$body" diff --git a/README.md b/README.md index b637b87a..eee8249b 100644 --- a/README.md +++ b/README.md @@ -538,6 +538,13 @@ By default (`cleanup-on-start-failure: true`), the instance is **terminated** af cleanup-on-start-failure: false # keep the instance for interactive debugging ``` +## Updating the pinned runner version + +The default `actions/runner` version is pinned (with SHA-256 checksums in `src/runner-checksums.js`) and bumped automatically: + +- **Automatic:** a weekly workflow (`.github/workflows/bump-runner.yml`) checks for a newer `actions/runner` release and, if found, opens a PR that updates the checksum table, `action.yml`, config, docs, and the rebuilt `dist/`. There is **no auto-merge** — review the PR (the body links the release notes and cites the checksum source) and merge it. +- **Manual / hotfix:** run `node scripts/bump-runner.js ` (e.g. `node scripts/bump-runner.js 2.336.0`). It fetches the release checksums, updates every file, and rebuilds `dist/` (running `npm ci` before `npm run package` for you). Commit and open a PR. + ## Self-hosted runner security with public repositories > We recommend that you do not use self-hosted runners with public repositories. diff --git a/dist/index.js b/dist/index.js index 7b3f58f9..172e3c14 100644 --- a/dist/index.js +++ b/dist/index.js @@ -106005,12 +106005,16 @@ module.exports = { // at boot time means an api.github.com round trip on every runner // start and hits the unauth rate limit quickly at org scale. // -// Maintenance: whenever the `runner-version` default bumps in -// action.yml, add the matching hashes here from the release body at -// https://github.com/actions/runner/releases/tag/v. The -// `verify-runner-url` CI job cross-checks every entry against the -// live release body on every PR, so a drift between this table and -// upstream is caught at code-review time, not at runtime. +// Maintenance: this table is bumped automatically. The weekly +// `Bump actions/runner` workflow (.github/workflows/bump-runner.yml) +// opens a PR via `scripts/bump-runner.js`, which adds the matching +// hashes here from the release body at +// https://github.com/actions/runner/releases/tag/v and +// rebuilds dist. For a manual/hotfix bump, run +// `node scripts/bump-runner.js ` (no auto-merge — a human +// reviews the PR). The `verify-runner-url` CI job cross-checks every +// entry against the live release body on every PR, so a drift between +// this table and upstream is caught at code-review time, not at runtime. // // Sources: // https://github.com/actions/runner/releases/tag/v2.335.1 diff --git a/scripts/bump-runner.js b/scripts/bump-runner.js new file mode 100644 index 00000000..b89b6a1e --- /dev/null +++ b/scripts/bump-runner.js @@ -0,0 +1,154 @@ +#!/usr/bin/env node +// Codifies the actions/runner version-bump recipe into one command, so the +// bump is a script run (or a merged bot PR) instead of a multi-file manual +// ritual. Updates the checksum table, the action.yml default, the config +// default, the docs, and the tests, then rebuilds dist — encoding the +// "npm ci before npm run package" gotcha in code, not prose. +// +// Usage: +// node scripts/bump-runner.js [--x64 ] [--arm64 ] [--no-build] +// +// When --x64/--arm64 are omitted, the SHA-256 values are read from the +// official actions/runner release body over the authenticated GitHub API. +// +// parseChecksums / applyBump / readCurrentVersion are exported for tests. +const fs = require('fs'); +const path = require('path'); +const { execSync } = require('child_process'); + +// Files whose embedded default-version string is bumped verbatim. The +// checksum table is handled separately (it accumulates entries rather than +// replacing them). +const VERSION_FILES = ['action.yml', 'src/config.js', 'README.md', 'tests/config.test.js']; +const CHECKSUMS_FILE = 'src/runner-checksums.js'; + +// Parse the linux x64 (and arm64, if present) SHA-256 from an actions/runner +// release body. The hashes are published as HTML-comment-wrapped markers: +// deadbeef... +// Tolerant of surrounding whitespace; fails loudly on format drift so we +// never write a garbage checksum. +function parseChecksums(releaseBody) { + const x64 = /BEGIN SHA linux-x64 -->\s*([a-f0-9]{64})/.exec(releaseBody); + if (!x64) { + throw new Error('Could not parse the linux-x64 SHA-256 from the release body (format drift?)'); + } + const arm64 = /BEGIN SHA linux-arm64 -->\s*([a-f0-9]{64})/.exec(releaseBody); + return { x64: x64[1], arm64: arm64 ? arm64[1] : null }; +} + +// Read the currently pinned default runner version from action.yml — the +// canonical source (the verify-runner-url CI job reads it the same way). +function readCurrentVersion(rootDir) { + const content = fs.readFileSync(path.join(rootDir, 'action.yml'), 'utf8'); + const match = /runner-version:[\s\S]*?default:\s*'([^']+)'/.exec(content); + if (!match) { + throw new Error('Could not locate the runner-version default in action.yml'); + } + return match[1]; +} + +// Apply the bump to the working tree at rootDir. Idempotent: a second run +// with the same version is a no-op. Returns { changed, oldVersion, newVersion }. +function applyBump(rootDir, newVersion, checksums) { + const oldVersion = readCurrentVersion(rootDir); + const checksumsPath = path.join(rootDir, CHECKSUMS_FILE); + let checksumsContent = fs.readFileSync(checksumsPath, 'utf8'); + const alreadyHasEntry = checksumsContent.includes(`'x64-${newVersion}'`); + + if (oldVersion === newVersion && alreadyHasEntry) { + return { changed: false, oldVersion, newVersion }; + } + + // 1. Checksum table — add the new entries (keep historical ones) and cite + // the source. Skip if the entry already exists (idempotency). + if (!alreadyHasEntry) { + if (!checksums || !checksums.x64) { + throw new Error(`No x64 checksum provided for ${newVersion}`); + } + const entryLines = [` 'x64-${newVersion}': '${checksums.x64}',`]; + if (checksums.arm64) { + entryLines.push(` 'arm64-${newVersion}': '${checksums.arm64}',`); + } + checksumsContent = checksumsContent.replace('const CHECKSUMS = {', `const CHECKSUMS = {\n${entryLines.join('\n')}`); + checksumsContent = checksumsContent.replace('// Sources:\n', `// Sources:\n// https://github.com/actions/runner/releases/tag/v${newVersion}\n`); + fs.writeFileSync(checksumsPath, checksumsContent); + } + + // 2. Bump the embedded default-version string everywhere else. + if (oldVersion !== newVersion) { + for (const rel of VERSION_FILES) { + const filePath = path.join(rootDir, rel); + if (!fs.existsSync(filePath)) { + continue; + } + const before = fs.readFileSync(filePath, 'utf8'); + const after = before.split(oldVersion).join(newVersion); + if (after !== before) { + fs.writeFileSync(filePath, after); + } + } + } + + return { changed: true, oldVersion, newVersion }; +} + +async function fetchReleaseBody(version) { + const token = process.env.GITHUB_TOKEN || process.env.GH_TOKEN; + const headers = { Accept: 'application/vnd.github+json', 'User-Agent': 'ec2-github-runner-bump' }; + if (token) { + headers.Authorization = `Bearer ${token}`; + } + const res = await fetch(`https://api.github.com/repos/actions/runner/releases/tags/v${version}`, { headers }); + if (!res.ok) { + throw new Error(`GitHub API returned ${res.status} fetching actions/runner release v${version}`); + } + const data = await res.json(); + return data.body || ''; +} + +function argValue(args, flag) { + const i = args.indexOf(flag); + return i >= 0 ? args[i + 1] : undefined; +} + +async function main() { + const args = process.argv.slice(2); + const version = args.find((a) => !a.startsWith('--')); + if (!version) { + console.error('usage: node scripts/bump-runner.js [--x64 ] [--arm64 ] [--no-build]'); + process.exit(1); + } + + let x64 = argValue(args, '--x64'); + let arm64 = argValue(args, '--arm64'); + if (!x64) { + const body = await fetchReleaseBody(version); + const parsed = parseChecksums(body); + x64 = parsed.x64; + arm64 = parsed.arm64; + } + + const root = path.resolve(__dirname, '..'); + const result = applyBump(root, version, { x64, arm64 }); + if (!result.changed) { + console.log(`Already pinned to actions/runner v${version}; nothing to do.`); + return; + } + + // Encode the recipe's gotcha: npm ci BEFORE npm run package, or verify-dist + // churns on a dirty node_modules. + if (!args.includes('--no-build')) { + execSync('npm ci', { cwd: root, stdio: 'inherit' }); + execSync('npm run package', { cwd: root, stdio: 'inherit' }); + } + console.log(`Bumped actions/runner ${result.oldVersion} -> ${result.newVersion} and rebuilt dist.`); +} + +if (require.main === module) { + main().catch((error) => { + console.error(error.message); + process.exit(1); + }); +} + +module.exports = { parseChecksums, readCurrentVersion, applyBump }; diff --git a/src/runner-checksums.js b/src/runner-checksums.js index 9fa5f6af..0e70c4da 100644 --- a/src/runner-checksums.js +++ b/src/runner-checksums.js @@ -9,12 +9,16 @@ // at boot time means an api.github.com round trip on every runner // start and hits the unauth rate limit quickly at org scale. // -// Maintenance: whenever the `runner-version` default bumps in -// action.yml, add the matching hashes here from the release body at -// https://github.com/actions/runner/releases/tag/v. The -// `verify-runner-url` CI job cross-checks every entry against the -// live release body on every PR, so a drift between this table and -// upstream is caught at code-review time, not at runtime. +// Maintenance: this table is bumped automatically. The weekly +// `Bump actions/runner` workflow (.github/workflows/bump-runner.yml) +// opens a PR via `scripts/bump-runner.js`, which adds the matching +// hashes here from the release body at +// https://github.com/actions/runner/releases/tag/v and +// rebuilds dist. For a manual/hotfix bump, run +// `node scripts/bump-runner.js ` (no auto-merge — a human +// reviews the PR). The `verify-runner-url` CI job cross-checks every +// entry against the live release body on every PR, so a drift between +// this table and upstream is caught at code-review time, not at runtime. // // Sources: // https://github.com/actions/runner/releases/tag/v2.335.1 diff --git a/tests/bump-runner.test.js b/tests/bump-runner.test.js new file mode 100644 index 00000000..0986f8b0 --- /dev/null +++ b/tests/bump-runner.test.js @@ -0,0 +1,93 @@ +// Tests for scripts/bump-runner.js — parseChecksums against a real-format +// release-body fixture, and applyBump against a copy of the actual repo +// files in a temp tree (so the test tracks the real file formats), including +// an idempotency check. +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const { parseChecksums, applyBump, readCurrentVersion } = require('../scripts/bump-runner'); + +const REPO_ROOT = path.resolve(__dirname, '..'); +const COPIED_FILES = ['action.yml', 'src/config.js', 'src/runner-checksums.js', 'README.md', 'tests/config.test.js']; + +function makeTempRepo() { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'bump-runner-')); + for (const rel of COPIED_FILES) { + const dest = path.join(dir, rel); + fs.mkdirSync(path.dirname(dest), { recursive: true }); + fs.copyFileSync(path.join(REPO_ROOT, rel), dest); + } + return dir; +} + +const readAll = (dir) => Object.fromEntries(COPIED_FILES.map((rel) => [rel, fs.readFileSync(path.join(dir, rel), 'utf8')])); + +describe('parseChecksums', () => { + const body = fs.readFileSync(path.join(__dirname, 'fixtures', 'runner-release-body.txt'), 'utf8'); + + test('extracts the linux x64 and arm64 SHA-256 from the release body', () => { + expect(parseChecksums(body)).toEqual({ + x64: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + arm64: 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', + }); + }); + + test('tolerates whitespace after the marker', () => { + const spaced = body.replace('linux-x64 -->a', 'linux-x64 -->\n a'); + expect(parseChecksums(spaced).x64).toMatch(/^a{64}$/); + }); + + test('returns null arm64 when the release has no arm64 marker (single-arch)', () => { + const noArm = body.replace(/- Actions Runner \(Linux ARM64\).*\n/, ''); + expect(parseChecksums(noArm).arm64).toBeNull(); + }); + + test('throws loudly on format drift instead of writing garbage', () => { + expect(() => parseChecksums('no checksums here')).toThrow(/format drift/); + }); +}); + +describe('applyBump', () => { + const NEW = '9.9.9'; + const X64 = 'c'.repeat(64); + const ARM64 = 'd'.repeat(64); + + test('updates every recipe file and adds the checksum entries', () => { + const dir = makeTempRepo(); + const oldVersion = readCurrentVersion(dir); + const result = applyBump(dir, NEW, { x64: X64, arm64: ARM64 }); + + expect(result).toMatchObject({ changed: true, oldVersion, newVersion: NEW }); + + const files = readAll(dir); + // Checksum table gained both arch entries (historical entry preserved). + expect(files['src/runner-checksums.js']).toContain(`'x64-${NEW}':`); + expect(files['src/runner-checksums.js']).toContain(X64); + expect(files['src/runner-checksums.js']).toContain(`'arm64-${NEW}':`); + expect(files['src/runner-checksums.js']).toContain(`'x64-${oldVersion}':`); // not removed + // Default version bumped in action.yml + config + docs + tests. + expect(files['action.yml']).toContain(`default: '${NEW}'`); + expect(files['src/config.js']).toContain(`|| '${NEW}'`); + expect(files['tests/config.test.js']).toContain(NEW); + expect(files['action.yml']).not.toContain(`default: '${oldVersion}'`); + }); + + test('is idempotent — a second run makes no further changes', () => { + const dir = makeTempRepo(); + applyBump(dir, NEW, { x64: X64, arm64: ARM64 }); + const afterFirst = readAll(dir); + + const second = applyBump(dir, NEW, { x64: X64, arm64: ARM64 }); + expect(second.changed).toBe(false); + expect(readAll(dir)).toEqual(afterFirst); + }); + + test('omits the arm64 entry when no arm64 checksum is supplied', () => { + const dir = makeTempRepo(); + applyBump(dir, NEW, { x64: X64, arm64: null }); + const checksums = fs.readFileSync(path.join(dir, 'src/runner-checksums.js'), 'utf8'); + expect(checksums).toContain(`'x64-${NEW}':`); + expect(checksums).not.toContain(`'arm64-${NEW}':`); + }); +}); diff --git a/tests/fixtures/runner-release-body.txt b/tests/fixtures/runner-release-body.txt new file mode 100644 index 00000000..17b26014 --- /dev/null +++ b/tests/fixtures/runner-release-body.txt @@ -0,0 +1,12 @@ +## What's Changed + +This release contains a number of bug fixes and improvements. + +## SHA-256 Checksums + +The SHA-256 checksums for the packages included in this build are shown below: + +- Actions Runner (OSX x64): 1111111111111111111111111111111111111111111111111111111111111111 +- Actions Runner (Linux x64): aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa +- Actions Runner (Linux ARM64): bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb +- Actions Runner (Win x64): 2222222222222222222222222222222222222222222222222222222222222222