Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 96 additions & 0 deletions .github/workflows/bump-runner.yml
Original file line number Diff line number Diff line change
@@ -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"
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <version>` (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.
Expand Down
16 changes: 10 additions & 6 deletions dist/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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<version>. 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<version> and
// rebuilds dist. For a manual/hotfix bump, run
// `node scripts/bump-runner.js <version>` (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
Expand Down
154 changes: 154 additions & 0 deletions scripts/bump-runner.js
Original file line number Diff line number Diff line change
@@ -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 <version> [--x64 <sha>] [--arm64 <sha>] [--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:
// <!-- BEGIN SHA linux-x64 -->deadbeef...<!-- END SHA linux-x64 -->
// 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 <version> [--x64 <sha>] [--arm64 <sha>] [--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 };
16 changes: 10 additions & 6 deletions src/runner-checksums.js
Original file line number Diff line number Diff line change
Expand Up @@ -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<version>. 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<version> and
// rebuilds dist. For a manual/hotfix bump, run
// `node scripts/bump-runner.js <version>` (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
Expand Down
Loading