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
49 changes: 48 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

![GitHub Actions self-hosted EC2 runner](docs/images/github-actions-runner.gif)

Expand Down Expand Up @@ -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`). <br><br> 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`). <br><br> - `required` — IMDSv2 only; mitigates SSRF-style credential theft. <br> - `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). |
Expand Down Expand Up @@ -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/<owner>/<repo>` |
| `{{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:
Expand Down
20 changes: 20 additions & 0 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
129 changes: 127 additions & 2 deletions dist/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:<step> tag.',
Expand Down Expand Up @@ -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();

Expand All @@ -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;
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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)) {
Expand Down Expand Up @@ -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:
Expand Down
41 changes: 41 additions & 0 deletions examples/user-data/README.md
Original file line number Diff line number Diff line change
@@ -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/<owner>/<repo>` |
| `{{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.
45 changes: 45 additions & 0 deletions examples/user-data/ubuntu.sh.tpl
Original file line number Diff line number Diff line change
@@ -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
Loading