diff --git a/README.md b/README.md
index 6644b2e5..20c7643b 100644
--- a/README.md
+++ b/README.md
@@ -12,7 +12,7 @@ And all this automatically as a part of your GitHub Actions workflow.
>
> The bootstrap script that this action injects as EC2 `user-data` is hardcoded to use `yum`, `useradd`, `sudo`, `bash`, and a `tmpfs` `/tmp`. That means the AMI you pass via `ec2-image-id` **must** be a yum-based distribution — Amazon Linux 2023 (the tested baseline), Amazon Linux 2, or a RHEL-family image (RHEL / CentOS Stream / Rocky / Alma) whose `/tmp` is mounted as tmpfs.
>
-> **Debian, Ubuntu, Alpine, and any other non-yum distributions are not supported.** If you launch this action against such an AMI, the EC2 instance will boot but the runner bootstrap will fail. The action now surfaces this quickly: it fails fast naming the failing step and prints the instance's console output (see [Troubleshooting a failed start](#troubleshooting-a-failed-start)). Cross-distro support is not on the roadmap — if you need it, fork and replace the `userData` bootstrap in `src/aws.js`.
+> **The built-in bootstrap targets yum-based Linux (Amazon Linux 2023) only.** Launching the built-in bootstrap against a non-yum AMI (Debian, Ubuntu, Alpine, …) fails — the action surfaces this fast, naming the failing step and printing the console output (see [Troubleshooting a failed start](#troubleshooting-a-failed-start)). You **don't need to fork** for other distros or extra setup: use [`pre-runner-script`](#custom-bootstrap-pre-runner-script--user-data-template) to inject steps into the built-in bootstrap, or [`user-data-template`](#custom-bootstrap-pre-runner-script--user-data-template) to replace it entirely (an Ubuntu example ships in [`examples/user-data/`](examples/user-data/)). Custom templates are unsupported by design — the boundary *is* the feature.

@@ -323,6 +323,8 @@ Now you're ready to go!
| `eip-allocation-id` | Optional. Used only with the `start` mode. | Allocation Id of an Elastic IP to associate with the runner instance once it is running. |
| `runner-version` | Optional. Used only with the `start` mode. | Version of the `actions/runner` binary to download and register (default `2.335.1`).
Must have a matching entry in `src/runner-checksums.js`; the action verifies the downloaded tarball's SHA-256 against that table before extraction. |
| `architecture` | Optional. Used only with the `start` mode. | Runner CPU architecture: `x64` (default) or `arm64` (Graviton). Must match the AMI (validated at start). All types in an `ec2-instance-type` fallback list must share this arch. See [Running on Graviton (arm64)](#running-on-graviton-arm64). |
+| `pre-runner-script` | Optional. Used only with the `start` mode. | Shell snippet run as root by the built-in bootstrap **before** runner config (install docker, mount caches, add certs). Fail-fast, tagged `failed:pre-runner-script`. Mutually exclusive with `user-data-template`. See [Custom bootstrap](#custom-bootstrap-pre-runner-script--user-data-template). |
+| `user-data-template` | Optional. Used only with the `start` mode. | Full bootstrap override — a repo-relative file path or inline string with `{{PLACEHOLDERS}}`. Replaces the built-in bootstrap (unsupported by design). Mutually exclusive with `pre-runner-script`. See [Custom bootstrap](#custom-bootstrap-pre-runner-script--user-data-template). |
| `http-tokens` | Optional. Used only with the `start` mode. | Instance Metadata Service (IMDS) token mode (default `required`).
- `required` — IMDSv2 only; mitigates SSRF-style credential theft.
- `optional` — also allows IMDSv1; set only if a workload on the runner needs it. |
| `encrypt-ebs` | Optional. Used only with the `start` mode. | When `true`, the root EBS volume is created with SSE-EBS encryption using the account's default AWS-managed key (default `false`). Volume size / type / IOPS are preserved from the AMI unless overridden by the `volume-*` inputs below. |
| `volume-size` | Optional. Used only with the `start` mode. | Root EBS volume size in GiB. Omitted = AMI default (Amazon Linux 2023: 8 GiB). Must be ≥ the AMI snapshot size. See [Disk space for Docker workloads](#disk-space-for-docker-workloads). |
@@ -524,6 +526,51 @@ A single `subnet-id` + `ec2-instance-type` means a single point of failure: when
The `instance-type-used` and `subnet-id-used` outputs report what actually launched. Single values keep the original single-attempt behavior.
+## Custom bootstrap (`pre-runner-script` / `user-data-template`)
+
+Need an extra package or a different distro? Two escape hatches mean you never have to fork.
+
+### `pre-runner-script` — the 80% case
+
+Inject shell into the built-in (supported) bootstrap, run as root before the runner registers:
+
+```yml
+- uses: namecheap/ec2-github-runner@v3
+ with:
+ mode: start
+ # ... other inputs ...
+ pre-runner-script: |
+ yum install -y docker
+ systemctl start docker
+```
+
+It runs under `set -euo pipefail` and a failure is tagged `failed:pre-runner-script`, so it shows up in the [fast-fail diagnostics](#troubleshooting-a-failed-start) like any other phase.
+
+### `user-data-template` — full control
+
+Replace the bootstrap entirely with your own script (repo-relative path or inline string). The action substitutes documented placeholders and submits the result:
+
+```yml
+- uses: namecheap/ec2-github-runner@v3
+ with:
+ mode: start
+ # ... other inputs ...
+ user-data-template: ./examples/user-data/ubuntu.sh.tpl
+```
+
+| Placeholder | Value |
+| --- | --- |
+| `{{RUNNER_VERSION}}` | Pinned `actions/runner` version |
+| `{{RUNNER_CHECKSUM_X64}}` / `{{RUNNER_CHECKSUM_ARM64}}` | Tarball SHA-256 per arch |
+| `{{REGISTRATION_TOKEN}}` | Ephemeral registration token (secret) |
+| `{{REPO_URL}}` | `https://github.com//` |
+| `{{LABEL}}` | The unique runner label |
+| `{{TTL_MINUTES}}` | `max-lifetime-minutes` (`0` = disabled) |
+
+Unknown `{{...}}` tokens fail the run (typo protection); the rendered payload must stay under the EC2 16 KB limit.
+
+**Support boundary:** the built-in yum bootstrap is the only *supported* path. With a custom template, the action renders your placeholders and gives you the diagnostics tooling — but the script is yours. See [`examples/user-data/`](examples/user-data/) (an Ubuntu 24.04 template ships there as a community-maintained starting point). The two inputs are mutually exclusive.
+
## Running on Graviton (arm64)
Graviton instances (c7g/m7g/r7g/…) deliver ~20–40% better price/performance for the compile/test workloads CI runs, and Go/Rust/Node/Java toolchains are all arm64-native. Set `architecture: arm64` and point at an arm64 AMI:
diff --git a/action.yml b/action.yml
index ab03f7ad..876ec3e3 100644
--- a/action.yml
+++ b/action.yml
@@ -118,6 +118,26 @@ inputs:
change is needed to run on Graviton.
required: false
default: 'x64'
+ pre-runner-script:
+ description: >-
+ Used only with the 'start' mode. A shell snippet run as root by the
+ built-in bootstrap BEFORE runner configuration (e.g. install docker,
+ mount caches, add corporate certs). Runs under set -euo pipefail; a
+ failure is tagged 'failed:pre-runner-script'. Mutually exclusive with
+ user-data-template.
+ required: false
+ user-data-template:
+ description: >-
+ Used only with the 'start' mode. Full user-data bootstrap override —
+ a repo-relative file path or an inline template string. The action
+ substitutes the documented placeholders ({{RUNNER_VERSION}},
+ {{RUNNER_CHECKSUM_X64}}, {{RUNNER_CHECKSUM_ARM64}},
+ {{REGISTRATION_TOKEN}}, {{REPO_URL}}, {{LABEL}}, {{TTL_MINUTES}}) and
+ submits the result as-is. The template MUST register the runner.
+ Custom templates are unsupported (the built-in bootstrap is the
+ supported path); see examples/user-data/. Mutually exclusive with
+ pre-runner-script.
+ required: false
encrypt-ebs:
description: >-
When 'true', the root EBS volume is created with SSE-EBS
diff --git a/dist/index.js b/dist/index.js
index c2f7f35f..d7d8401f 100644
--- a/dist/index.js
+++ b/dist/index.js
@@ -104659,11 +104659,13 @@ const {
AssociateAddressCommand,
waitUntilInstanceRunning,
} = __nccwpck_require__(5193);
+const fs = __nccwpck_require__(9896);
const core = __nccwpck_require__(7484);
const config = __nccwpck_require__(1283);
const log = __nccwpck_require__(7223);
const { withRetry } = __nccwpck_require__(6759);
const { sortByCreationDate, parseCsv } = __nccwpck_require__(5804);
+const { renderUserDataTemplate, assertUserDataSize } = __nccwpck_require__(9275);
const checksums = __nccwpck_require__(2874);
// RunInstances error classification for the capacity-fallback chain.
@@ -104994,7 +104996,21 @@ function buildRootDeviceMapping(image, opts = {}) {
// registration timeout. Tagging is best-effort: it needs
// `ec2:CreateTags` on the instance profile and degrades to
// timeout-only detection when absent (every write is `|| true`).
-function buildUserData({ runnerVersion, owner, repo, label, githubRegistrationToken, shaX64, shaArm64, maxLifetimeMinutes }) {
+function buildUserData({ runnerVersion, owner, repo, label, githubRegistrationToken, shaX64, shaArm64, maxLifetimeMinutes, preRunnerScript }) {
+ // User-supplied pre-runner-script (#46): injected verbatim into the outer
+ // (root) shell before runner configuration, under the same set -euo
+ // pipefail + ERR trap, tagged as its own phase so a failure surfaces as
+ // failed:pre-runner-script.
+ const preRunnerLines = preRunnerScript && preRunnerScript.trim()
+ ? [
+ '# User-supplied pre-runner-script (runs as root before runner config).',
+ 'GH_RUNNER_STEP=pre-runner-script',
+ 'gh_runner_phone_home pre-runner-script',
+ ...preRunnerScript.replace(/\r\n/g, '\n').split('\n'),
+ '',
+ ]
+ : [];
+
// TTL self-destruct (#42): arm a shutdown timer as an absolute upper
// bound on instance lifetime. Combined with
// InstanceInitiatedShutdownBehavior: terminate on RunInstances (set in
@@ -105053,6 +105069,7 @@ function buildUserData({ runnerVersion, owner, repo, label, githubRegistrationTo
' useradd -m -s /bin/bash runner',
'fi',
'',
+ ...preRunnerLines,
'# The runner-user shell owns the download/configure/register phases and',
'# reports them itself; drop the outer ERR trap so it does not overwrite',
'# the inner shell\'s more specific failed: tag.',
@@ -105127,6 +105144,15 @@ function buildTagSpecifications(label, startedAtIso) {
];
}
+// Resolve the user-data-template input: a repo-relative path is read from
+// disk; anything else is treated as an inline template string.
+function resolveUserDataTemplate(templateInput) {
+ if (fs.existsSync(templateInput)) {
+ return fs.readFileSync(templateInput, 'utf8');
+ }
+ return templateInput;
+}
+
async function startEc2Instance(label, githubRegistrationToken) {
const client = ec2Client();
@@ -105144,7 +105170,30 @@ async function startEc2Instance(label, githubRegistrationToken) {
}
const maxLifetimeMinutes = config.input.maxLifetimeMinutes;
- const userData = buildUserData({ runnerVersion, owner, repo, label, githubRegistrationToken, shaX64, shaArm64, maxLifetimeMinutes });
+
+ // Bootstrap source (#46): a full user-data-template override renders the
+ // documented placeholders; otherwise the built-in yum bootstrap is used,
+ // optionally with an injected pre-runner-script. Either way the rendered
+ // payload is size-checked against the EC2 16 KB limit.
+ let userData;
+ if (config.input.userDataTemplate) {
+ const template = resolveUserDataTemplate(config.input.userDataTemplate);
+ userData = renderUserDataTemplate(template, {
+ RUNNER_VERSION: runnerVersion,
+ RUNNER_CHECKSUM_X64: shaX64,
+ RUNNER_CHECKSUM_ARM64: shaArm64,
+ REGISTRATION_TOKEN: githubRegistrationToken,
+ REPO_URL: `https://github.com/${owner}/${repo}`,
+ LABEL: label,
+ TTL_MINUTES: maxLifetimeMinutes,
+ });
+ } else {
+ userData = buildUserData({
+ runnerVersion, owner, repo, label, githubRegistrationToken, shaX64, shaArm64, maxLifetimeMinutes,
+ preRunnerScript: config.input.preRunnerScript,
+ });
+ }
+ assertUserDataSize(userData);
const resolved = await resolveImage(client);
config.input.ec2ImageId = resolved.id;
@@ -105682,6 +105731,8 @@ class Config {
ec2InstanceIds: core.getInput('ec2-instance-ids') ? JSON.parse(core.getInput('ec2-instance-ids')) : null,
count: core.getInput('count') || '1',
allowPartial: core.getInput('allow-partial') || 'false',
+ preRunnerScript: core.getInput('pre-runner-script'),
+ userDataTemplate: core.getInput('user-data-template'),
iamRoleName: core.getInput('iam-role-name'),
runnerVersion: core.getInput('runner-version') || '2.335.1',
architecture: core.getInput('architecture') || 'x64',
@@ -105735,6 +105786,7 @@ class Config {
this.validateMarketInputs();
this.validateArchitectureInputs();
this.validateCountInput();
+ this.validateBootstrapInputs();
} else if (this.input.mode === 'stop') {
// A stop needs the shared label plus at least one instance id — either
// the compat scalar or the JSON array from a batched start.
@@ -105813,6 +105865,14 @@ class Config {
}
}
+ // The two bootstrap-extension inputs are mutually exclusive: pre-runner-
+ // script augments the built-in bootstrap, user-data-template replaces it.
+ validateBootstrapInputs() {
+ if (this.input.userDataTemplate && this.input.preRunnerScript) {
+ throw new Error(`'user-data-template' and 'pre-runner-script' are mutually exclusive`);
+ }
+ }
+
// Validate the multi-runner batch size.
validateCountInput() {
if (!(/^[0-9]+$/.test(this.input.count) && Number(this.input.count) >= 1)) {
@@ -106159,6 +106219,71 @@ module.exports = {
};
+/***/ }),
+
+/***/ 9275:
+/***/ ((module) => {
+
+// User-data template rendering for the `user-data-template` input (full
+// bootstrap override). The action renders a fixed set of placeholders and
+// submits the result verbatim; the script itself is the user's business.
+// Pure functions — no I/O — so rendering and validation are unit-testable.
+
+// EC2 caps user-data at 16 KB (raw, before base64). Guard against it up
+// front with a clear error rather than a cryptic RunInstances rejection.
+const MAX_USER_DATA_BYTES = 16 * 1024;
+
+// The documented placeholders a template may reference.
+const PLACEHOLDERS = [
+ 'RUNNER_VERSION',
+ 'RUNNER_CHECKSUM_X64',
+ 'RUNNER_CHECKSUM_ARM64',
+ 'REGISTRATION_TOKEN',
+ 'REPO_URL',
+ 'LABEL',
+ 'TTL_MINUTES',
+];
+
+// Substitute every documented {{PLACEHOLDER}} with its value, then fail if
+// any unknown {{TOKEN}} remains (catches typos before boot). Unused known
+// placeholders are fine.
+function renderUserDataTemplate(template, vars) {
+ let out = template;
+ for (const key of PLACEHOLDERS) {
+ const value = vars[key] != null ? String(vars[key]) : '';
+ out = out.split(`{{${key}}}`).join(value);
+ }
+ const unknown = [...out.matchAll(/\{\{\s*([A-Za-z0-9_]+)\s*\}\}/g)].map((m) => m[1]);
+ if (unknown.length > 0) {
+ const uniq = [...new Set(unknown)];
+ throw new Error(
+ `Unknown placeholder(s) in user-data-template: ${uniq.join(', ')}. ` +
+ `Supported placeholders: ${PLACEHOLDERS.join(', ')}`,
+ );
+ }
+ return out;
+}
+
+// Throw if the rendered user-data exceeds the EC2 16 KB limit.
+function assertUserDataSize(userData) {
+ const bytes = Buffer.byteLength(userData, 'utf8');
+ if (bytes > MAX_USER_DATA_BYTES) {
+ throw new Error(
+ `Rendered user-data is ${bytes} bytes, over the EC2 limit of ${MAX_USER_DATA_BYTES} bytes. ` +
+ 'Trim the pre-runner-script / user-data-template (fetch large payloads at runtime instead).',
+ );
+ }
+ return userData;
+}
+
+module.exports = {
+ renderUserDataTemplate,
+ assertUserDataSize,
+ MAX_USER_DATA_BYTES,
+ PLACEHOLDERS,
+};
+
+
/***/ }),
/***/ 5804:
diff --git a/examples/user-data/README.md b/examples/user-data/README.md
new file mode 100644
index 00000000..28de6b1c
--- /dev/null
+++ b/examples/user-data/README.md
@@ -0,0 +1,41 @@
+# Custom user-data templates
+
+> **Support boundary.** The built-in **yum-based** bootstrap (Amazon Linux 2023) is the only *supported* path. Templates in this directory are **community-maintained starting points**. When you supply `user-data-template`, the action renders your placeholders and gives you the same diagnostics tooling (bootstrap phone-home tags, console capture, cleanup) — but the script is yours to maintain. Bugs in a custom bootstrap are not action bugs.
+
+## Using a template
+
+```yml
+- uses: namecheap/ec2-github-runner@v3
+ with:
+ mode: start
+ github-token: ${{ secrets.GH_PERSONAL_ACCESS_TOKEN }}
+ ec2-image-id: ami-ubuntu-... # an Ubuntu AMI
+ ec2-instance-type: c7i.4xlarge
+ subnet-id: subnet-123
+ security-group-id: sg-123
+ user-data-template: ./examples/user-data/ubuntu.sh.tpl
+```
+
+`user-data-template` accepts a **repo-relative file path** or an **inline string**.
+
+## Placeholders
+
+The action substitutes these before launch (unknown `{{...}}` tokens fail the run so typos are caught early):
+
+| Placeholder | Value |
+| --- | --- |
+| `{{RUNNER_VERSION}}` | Pinned `actions/runner` version |
+| `{{RUNNER_CHECKSUM_X64}}` | SHA-256 of the linux-x64 tarball |
+| `{{RUNNER_CHECKSUM_ARM64}}` | SHA-256 of the linux-arm64 tarball |
+| `{{REGISTRATION_TOKEN}}` | Ephemeral runner registration token (secret — never logged) |
+| `{{REPO_URL}}` | `https://github.com//` |
+| `{{LABEL}}` | The unique runner label |
+| `{{TTL_MINUTES}}` | `max-lifetime-minutes` (`0` = disabled) |
+
+**Contract:** the template must register the runner (`config.sh … --ephemeral --unattended` then `run.sh`). Verify the tarball checksum. Everything else is your distro's business. Rendered user-data must stay under the EC2 16 KB limit — fetch large payloads at runtime.
+
+## Templates here
+
+- [`ubuntu.sh.tpl`](ubuntu.sh.tpl) — Ubuntu 24.04 (community-maintained).
+
+For a one-package tweak (e.g. install docker) you usually don't need a full template — use `pre-runner-script` instead, which keeps the supported bootstrap.
diff --git a/examples/user-data/ubuntu.sh.tpl b/examples/user-data/ubuntu.sh.tpl
new file mode 100644
index 00000000..077050d1
--- /dev/null
+++ b/examples/user-data/ubuntu.sh.tpl
@@ -0,0 +1,45 @@
+#!/bin/bash
+# Community-maintained user-data-template for Ubuntu (24.04 tested).
+#
+# UNSUPPORTED: the built-in yum bootstrap is the only supported path. This
+# template is a starting point so Ubuntu users copy one file instead of
+# forking. The action substitutes the {{PLACEHOLDERS}} before launch; keep
+# them intact. Pass it via: user-data-template: ./examples/user-data/ubuntu.sh.tpl
+set -euo pipefail
+
+# TTL self-destruct (max-lifetime-minutes). Requires the action's
+# InstanceInitiatedShutdownBehavior=terminate, which it sets when TTL > 0.
+if [ "{{TTL_MINUTES}}" != "0" ]; then
+ shutdown -h +{{TTL_MINUTES}} || true
+fi
+
+export DEBIAN_FRONTEND=noninteractive
+apt-get update -y
+apt-get install -y curl tar libicu-dev sudo
+
+# Non-root runner user (idempotent).
+if ! id runner >/dev/null 2>&1; then
+ useradd -m -s /bin/bash runner
+fi
+
+sudo -u runner -H bash <<'RUNNER_BOOTSTRAP'
+set -euo pipefail
+cd "$HOME"
+mkdir -p actions-runner && cd actions-runner
+
+case "$(uname -m)" in
+ aarch64) RUNNER_ARCH="arm64"; EXPECTED_SHA="{{RUNNER_CHECKSUM_ARM64}}" ;;
+ amd64|x86_64) RUNNER_ARCH="x64"; EXPECTED_SHA="{{RUNNER_CHECKSUM_X64}}" ;;
+ *) echo "unsupported arch: $(uname -m)" >&2; exit 1 ;;
+esac
+
+RUNNER_VERSION="{{RUNNER_VERSION}}"
+TARBALL="actions-runner-linux-${RUNNER_ARCH}-${RUNNER_VERSION}.tar.gz"
+curl -fsSLo "$TARBALL" "https://github.com/actions/runner/releases/download/v${RUNNER_VERSION}/${TARBALL}"
+echo "$EXPECTED_SHA $TARBALL" | sha256sum -c -
+tar xzf "$TARBALL"
+
+export DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=1
+./config.sh --url "{{REPO_URL}}" --token "{{REGISTRATION_TOKEN}}" --labels "{{LABEL}}" --ephemeral --unattended --disableupdate
+./run.sh
+RUNNER_BOOTSTRAP
diff --git a/src/aws.js b/src/aws.js
index 9e378986..84d7e3b3 100644
--- a/src/aws.js
+++ b/src/aws.js
@@ -9,11 +9,13 @@ const {
AssociateAddressCommand,
waitUntilInstanceRunning,
} = require('@aws-sdk/client-ec2');
+const fs = require('fs');
const core = require('@actions/core');
const config = require('./config');
const log = require('./log');
const { withRetry } = require('./retry');
const { sortByCreationDate, parseCsv } = require('./utils');
+const { renderUserDataTemplate, assertUserDataSize } = require('./template');
const checksums = require('./runner-checksums');
// RunInstances error classification for the capacity-fallback chain.
@@ -344,7 +346,21 @@ function buildRootDeviceMapping(image, opts = {}) {
// registration timeout. Tagging is best-effort: it needs
// `ec2:CreateTags` on the instance profile and degrades to
// timeout-only detection when absent (every write is `|| true`).
-function buildUserData({ runnerVersion, owner, repo, label, githubRegistrationToken, shaX64, shaArm64, maxLifetimeMinutes }) {
+function buildUserData({ runnerVersion, owner, repo, label, githubRegistrationToken, shaX64, shaArm64, maxLifetimeMinutes, preRunnerScript }) {
+ // User-supplied pre-runner-script (#46): injected verbatim into the outer
+ // (root) shell before runner configuration, under the same set -euo
+ // pipefail + ERR trap, tagged as its own phase so a failure surfaces as
+ // failed:pre-runner-script.
+ const preRunnerLines = preRunnerScript && preRunnerScript.trim()
+ ? [
+ '# User-supplied pre-runner-script (runs as root before runner config).',
+ 'GH_RUNNER_STEP=pre-runner-script',
+ 'gh_runner_phone_home pre-runner-script',
+ ...preRunnerScript.replace(/\r\n/g, '\n').split('\n'),
+ '',
+ ]
+ : [];
+
// TTL self-destruct (#42): arm a shutdown timer as an absolute upper
// bound on instance lifetime. Combined with
// InstanceInitiatedShutdownBehavior: terminate on RunInstances (set in
@@ -403,6 +419,7 @@ function buildUserData({ runnerVersion, owner, repo, label, githubRegistrationTo
' useradd -m -s /bin/bash runner',
'fi',
'',
+ ...preRunnerLines,
'# The runner-user shell owns the download/configure/register phases and',
'# reports them itself; drop the outer ERR trap so it does not overwrite',
'# the inner shell\'s more specific failed: tag.',
@@ -477,6 +494,15 @@ function buildTagSpecifications(label, startedAtIso) {
];
}
+// Resolve the user-data-template input: a repo-relative path is read from
+// disk; anything else is treated as an inline template string.
+function resolveUserDataTemplate(templateInput) {
+ if (fs.existsSync(templateInput)) {
+ return fs.readFileSync(templateInput, 'utf8');
+ }
+ return templateInput;
+}
+
async function startEc2Instance(label, githubRegistrationToken) {
const client = ec2Client();
@@ -494,7 +520,30 @@ async function startEc2Instance(label, githubRegistrationToken) {
}
const maxLifetimeMinutes = config.input.maxLifetimeMinutes;
- const userData = buildUserData({ runnerVersion, owner, repo, label, githubRegistrationToken, shaX64, shaArm64, maxLifetimeMinutes });
+
+ // Bootstrap source (#46): a full user-data-template override renders the
+ // documented placeholders; otherwise the built-in yum bootstrap is used,
+ // optionally with an injected pre-runner-script. Either way the rendered
+ // payload is size-checked against the EC2 16 KB limit.
+ let userData;
+ if (config.input.userDataTemplate) {
+ const template = resolveUserDataTemplate(config.input.userDataTemplate);
+ userData = renderUserDataTemplate(template, {
+ RUNNER_VERSION: runnerVersion,
+ RUNNER_CHECKSUM_X64: shaX64,
+ RUNNER_CHECKSUM_ARM64: shaArm64,
+ REGISTRATION_TOKEN: githubRegistrationToken,
+ REPO_URL: `https://github.com/${owner}/${repo}`,
+ LABEL: label,
+ TTL_MINUTES: maxLifetimeMinutes,
+ });
+ } else {
+ userData = buildUserData({
+ runnerVersion, owner, repo, label, githubRegistrationToken, shaX64, shaArm64, maxLifetimeMinutes,
+ preRunnerScript: config.input.preRunnerScript,
+ });
+ }
+ assertUserDataSize(userData);
const resolved = await resolveImage(client);
config.input.ec2ImageId = resolved.id;
diff --git a/src/config.js b/src/config.js
index af9ca41e..b22531ce 100644
--- a/src/config.js
+++ b/src/config.js
@@ -22,6 +22,8 @@ class Config {
ec2InstanceIds: core.getInput('ec2-instance-ids') ? JSON.parse(core.getInput('ec2-instance-ids')) : null,
count: core.getInput('count') || '1',
allowPartial: core.getInput('allow-partial') || 'false',
+ preRunnerScript: core.getInput('pre-runner-script'),
+ userDataTemplate: core.getInput('user-data-template'),
iamRoleName: core.getInput('iam-role-name'),
runnerVersion: core.getInput('runner-version') || '2.335.1',
architecture: core.getInput('architecture') || 'x64',
@@ -75,6 +77,7 @@ class Config {
this.validateMarketInputs();
this.validateArchitectureInputs();
this.validateCountInput();
+ this.validateBootstrapInputs();
} else if (this.input.mode === 'stop') {
// A stop needs the shared label plus at least one instance id — either
// the compat scalar or the JSON array from a batched start.
@@ -153,6 +156,14 @@ class Config {
}
}
+ // The two bootstrap-extension inputs are mutually exclusive: pre-runner-
+ // script augments the built-in bootstrap, user-data-template replaces it.
+ validateBootstrapInputs() {
+ if (this.input.userDataTemplate && this.input.preRunnerScript) {
+ throw new Error(`'user-data-template' and 'pre-runner-script' are mutually exclusive`);
+ }
+ }
+
// Validate the multi-runner batch size.
validateCountInput() {
if (!(/^[0-9]+$/.test(this.input.count) && Number(this.input.count) >= 1)) {
diff --git a/src/template.js b/src/template.js
new file mode 100644
index 00000000..41ae6da2
--- /dev/null
+++ b/src/template.js
@@ -0,0 +1,58 @@
+// User-data template rendering for the `user-data-template` input (full
+// bootstrap override). The action renders a fixed set of placeholders and
+// submits the result verbatim; the script itself is the user's business.
+// Pure functions — no I/O — so rendering and validation are unit-testable.
+
+// EC2 caps user-data at 16 KB (raw, before base64). Guard against it up
+// front with a clear error rather than a cryptic RunInstances rejection.
+const MAX_USER_DATA_BYTES = 16 * 1024;
+
+// The documented placeholders a template may reference.
+const PLACEHOLDERS = [
+ 'RUNNER_VERSION',
+ 'RUNNER_CHECKSUM_X64',
+ 'RUNNER_CHECKSUM_ARM64',
+ 'REGISTRATION_TOKEN',
+ 'REPO_URL',
+ 'LABEL',
+ 'TTL_MINUTES',
+];
+
+// Substitute every documented {{PLACEHOLDER}} with its value, then fail if
+// any unknown {{TOKEN}} remains (catches typos before boot). Unused known
+// placeholders are fine.
+function renderUserDataTemplate(template, vars) {
+ let out = template;
+ for (const key of PLACEHOLDERS) {
+ const value = vars[key] != null ? String(vars[key]) : '';
+ out = out.split(`{{${key}}}`).join(value);
+ }
+ const unknown = [...out.matchAll(/\{\{\s*([A-Za-z0-9_]+)\s*\}\}/g)].map((m) => m[1]);
+ if (unknown.length > 0) {
+ const uniq = [...new Set(unknown)];
+ throw new Error(
+ `Unknown placeholder(s) in user-data-template: ${uniq.join(', ')}. ` +
+ `Supported placeholders: ${PLACEHOLDERS.join(', ')}`,
+ );
+ }
+ return out;
+}
+
+// Throw if the rendered user-data exceeds the EC2 16 KB limit.
+function assertUserDataSize(userData) {
+ const bytes = Buffer.byteLength(userData, 'utf8');
+ if (bytes > MAX_USER_DATA_BYTES) {
+ throw new Error(
+ `Rendered user-data is ${bytes} bytes, over the EC2 limit of ${MAX_USER_DATA_BYTES} bytes. ` +
+ 'Trim the pre-runner-script / user-data-template (fetch large payloads at runtime instead).',
+ );
+ }
+ return userData;
+}
+
+module.exports = {
+ renderUserDataTemplate,
+ assertUserDataSize,
+ MAX_USER_DATA_BYTES,
+ PLACEHOLDERS,
+};
diff --git a/tests/config.test.js b/tests/config.test.js
index f6425032..11d50bcb 100644
--- a/tests/config.test.js
+++ b/tests/config.test.js
@@ -238,6 +238,25 @@ describe('Config — stop mode with instance batch', () => {
});
});
+describe('Config — bootstrap extension inputs', () => {
+ test('accepts a pre-runner-script alone', () => {
+ const config = loadConfig({ ...startModeInputs, 'pre-runner-script': 'yum install -y docker' });
+ expect(config.input.preRunnerScript).toBe('yum install -y docker');
+ });
+
+ test('accepts a user-data-template alone', () => {
+ const config = loadConfig({ ...startModeInputs, 'user-data-template': './ci/boot.sh.tpl' });
+ expect(config.input.userDataTemplate).toBe('./ci/boot.sh.tpl');
+ });
+
+ test('rejects setting both (mutually exclusive)', () => {
+ expectValidationFailure(
+ { ...startModeInputs, 'pre-runner-script': 'echo hi', 'user-data-template': './boot.tpl' },
+ /mutually exclusive/,
+ );
+ });
+});
+
describe('Config — architecture input', () => {
test('defaults to x64', () => {
expect(loadConfig(startModeInputs).input.architecture).toBe('x64');
diff --git a/tests/template.test.js b/tests/template.test.js
new file mode 100644
index 00000000..ef22d5b4
--- /dev/null
+++ b/tests/template.test.js
@@ -0,0 +1,55 @@
+// Tests for user-data template rendering + size guard (src/template.js).
+const { renderUserDataTemplate, assertUserDataSize, MAX_USER_DATA_BYTES } = require('../src/template');
+
+const vars = {
+ RUNNER_VERSION: '2.335.1',
+ RUNNER_CHECKSUM_X64: 'x64sha',
+ RUNNER_CHECKSUM_ARM64: 'arm64sha',
+ REGISTRATION_TOKEN: 'SECRET-TOKEN',
+ REPO_URL: 'https://github.com/my-org/my-repo',
+ LABEL: 'runner-abc12',
+ TTL_MINUTES: '360',
+};
+
+describe('renderUserDataTemplate', () => {
+ test('substitutes every documented placeholder', () => {
+ const tpl = 'v={{RUNNER_VERSION}} x={{RUNNER_CHECKSUM_X64}} a={{RUNNER_CHECKSUM_ARM64}} t={{REGISTRATION_TOKEN}} u={{REPO_URL}} l={{LABEL}} ttl={{TTL_MINUTES}}';
+ expect(renderUserDataTemplate(tpl, vars)).toBe(
+ 'v=2.335.1 x=x64sha a=arm64sha t=SECRET-TOKEN u=https://github.com/my-org/my-repo l=runner-abc12 ttl=360',
+ );
+ });
+
+ test('substitutes repeated placeholders', () => {
+ expect(renderUserDataTemplate('{{LABEL}}-{{LABEL}}', vars)).toBe('runner-abc12-runner-abc12');
+ });
+
+ test('leaves unused known placeholders absent without error', () => {
+ expect(renderUserDataTemplate('only {{LABEL}}', vars)).toBe('only runner-abc12');
+ });
+
+ test('throws listing unknown placeholders (typo protection)', () => {
+ expect(() => renderUserDataTemplate('{{RUNNER_VERION}} {{FOO}}', vars)).toThrow(/RUNNER_VERION, FOO/);
+ });
+
+ test('renders the token so the runner can register (secret handled by caller redaction)', () => {
+ expect(renderUserDataTemplate('--token {{REGISTRATION_TOKEN}}', vars)).toContain('SECRET-TOKEN');
+ });
+});
+
+describe('assertUserDataSize', () => {
+ test('passes payloads within the 16 KB limit', () => {
+ const ud = 'a'.repeat(1000);
+ expect(assertUserDataSize(ud)).toBe(ud);
+ });
+
+ test('throws for payloads over the limit', () => {
+ const ud = 'a'.repeat(MAX_USER_DATA_BYTES + 1);
+ expect(() => assertUserDataSize(ud)).toThrow(/over the EC2 limit/);
+ });
+
+ test('counts bytes, not characters (multibyte)', () => {
+ // Each '€' is 3 bytes in UTF-8.
+ const ud = '€'.repeat(Math.ceil(MAX_USER_DATA_BYTES / 3) + 1);
+ expect(() => assertUserDataSize(ud)).toThrow(/over the EC2 limit/);
+ });
+});
diff --git a/tests/userdata.test.js b/tests/userdata.test.js
index f56cd9e5..0366793f 100644
--- a/tests/userdata.test.js
+++ b/tests/userdata.test.js
@@ -103,4 +103,21 @@ describe('buildUserData', () => {
const ud = buildUserData(args);
expect(ud).not.toContain('shutdown -h');
});
+
+ test('injects a pre-runner-script with its own phase tag, before the runner-user handoff', () => {
+ const ud = buildUserData({ ...args, preRunnerScript: 'yum install -y docker\nsystemctl start docker' });
+ expect(ud).toContain('GH_RUNNER_STEP=pre-runner-script');
+ expect(ud).toContain('gh_runner_phone_home pre-runner-script');
+ expect(ud).toContain('yum install -y docker');
+ expect(ud).toContain('systemctl start docker');
+ // Runs as root before dropping to the runner user.
+ expect(ud.indexOf('GH_RUNNER_STEP=pre-runner-script')).toBeLessThan(ud.indexOf("sudo -u runner -H bash <<'RUNNER_BOOTSTRAP'"));
+ });
+
+ test('omits the pre-runner-script phase entirely when not provided (default byte-identical)', () => {
+ const ud = buildUserData(args);
+ expect(ud).not.toContain('pre-runner-script');
+ // whitespace-only script is treated as absent
+ expect(buildUserData({ ...args, preRunnerScript: ' \n ' })).not.toContain('pre-runner-script');
+ });
});