diff --git a/README.md b/README.md
index 20c7643b..d626b24d 100644
--- a/README.md
+++ b/README.md
@@ -316,6 +316,10 @@ Now you're ready to go!
| `label` | Required if you use the `stop` mode. | Name of the unique label assigned to the runner.
The label is provided by the output of the action in the `start` mode.
The label is used to remove the runner from GitHub when the runner is not needed anymore. |
| `count` | Optional. Used only with the `start` mode. | Number of runner instances to launch behind the single shared label (default `1`). Enables matrix builds — see [Matrix builds](#matrix-builds-multiple-runners). |
| `allow-partial` | Optional. Used only with the `start` mode (`count` > 1). | When `false` (default) the batch is all-or-nothing; when `true`, as few as 1 instance may launch, with the realized set in `ec2-instance-ids` and a warning. |
+| `reuse` | Optional. Used with `start` + `stop`. | `terminate` (default) or `stop`. `stop` enables [warm pools](#warm-pools-reuse-stop): reuse stopped instances for ~60% faster starts. Set the same value on both steps. **Unsafe for public/untrusted-PR repos** (disk state persists). |
+| `reuse-pool-tag` | Optional. Used with `reuse: stop`. | Pool identity — instances are interchangeable within a tag (default `default`). Use distinct tags per instance shape. |
+| `reuse-max-cycles` | Optional. Used with `reuse: stop`. | Recycle (terminate) a pool instance after it serves this many jobs (default `20`), so state doesn't accumulate forever. |
+| `reaper-stopped-max-age` | Optional. Used with the `cleanup` mode. | Terminate stopped pool instances older than this many minutes (default `1440` = 24h) so idle pools drain. |
| `ec2-instance-id` | Required for `stop` mode (or `ec2-instance-ids`). | EC2 Instance Id of the created runner.
The id is provided by the output of the action in the `start` mode.
The id is used to terminate the EC2 instance when the runner is not needed anymore. |
| `ec2-instance-ids` | Optional. Used with the `stop` mode. | JSON array of instance ids to terminate, from the `ec2-instance-ids` output of a batched `start` (e.g. `["i-aaa","i-bbb"]`). Either this or `ec2-instance-id` is required to stop. |
| `iam-role-name` | Optional. Used only with the `start` mode. | IAM role name to attach to the created EC2 runner.
This allows the runner to have permissions to run additional actions within the AWS account, without having to manage additional GitHub secrets and AWS users.
Setting this requires additional AWS permissions for the role launching the instance (see above). |
@@ -432,6 +436,37 @@ In [this discussion](https://github.com/machulav/ec2-github-runner/discussions/1
If you use this action in your workflow, feel free to add your story there as well 🙌
+## Warm pools (`reuse: stop`)
+
+Every cold start pays the full boot tax — instance launch + OS boot + `yum install` + runner download (~100 MB) + registration, typically 2–4 minutes. For lots of short jobs, boot time dominates wall-clock. Stopped EC2 instances restart in seconds-to-tens-of-seconds with their disk intact (bootstrap already done) and cost only EBS while stopped.
+
+Set `reuse: stop` on **both** the start and stop steps:
+
+```yml
+# start
+- uses: namecheap/ec2-github-runner@v3
+ with:
+ mode: start
+ reuse: stop
+ reuse-pool-tag: ci-medium # instances are interchangeable within a pool tag
+ # ... other inputs ...
+# stop
+- uses: namecheap/ec2-github-runner@v3
+ with:
+ mode: stop
+ reuse: stop
+ label: ${{ needs.start-runner.outputs.label }}
+ ec2-instance-id: ${{ needs.start-runner.outputs.ec2-instance-id }}
+```
+
+- **Start** looks for a *stopped* instance with this action's tags + matching `reuse-pool-tag` + same instance type/arch; if found it `StartInstances` and the runner re-registers (a boot-time hook reads a fresh registration token from the instance's IMDS user-data — the token never lives in a readable tag). If the pool is empty, it cold-launches an instance that **joins the pool**. (Warm reuse applies when `count` is 1; batches cold-launch.)
+- **Stop** stops the instance instead of terminating it, so the next job reuses it.
+- **Hygiene**: `reuse-max-cycles` (default 20) recycles an instance after N jobs; `max-lifetime-minutes` bounds wall-clock age; the [`cleanup` reaper](#reaping-orphaned-runners-mode-cleanup) drains stopped instances older than `reaper-stopped-max-age`. Warm caches (e.g. Docker layers) are a feature; unbounded state is not — these keep pools from accreting cost.
+
+> ⚠️ **Security:** reuse means job N+1 runs on job N's disk. This is fine for a **single trusted repo's** CI but **unsafe for public repositories or untrusted pull requests** — see [the security section](#self-hosted-runner-security-with-public-repositories).
+
+Pool sizing: match the pool tag to a concurrency tier. A pool naturally grows to your peak concurrency (cold launches join it) and drains via the reaper when idle.
+
## Matrix builds (multiple runners)
Matrix workflows need N runners. Instead of hand-wiring N start/stop jobs, launch a batch with `count` — all N register under the one shared label, and GitHub distributes the matrix jobs across them:
@@ -680,6 +715,8 @@ The default `actions/runner` version is pinned (with SHA-256 checksums in `src/r
Please find more details about this security note on [GitHub documentation](https://docs.github.com/en/free-pro-team@latest/actions/hosting-your-own-runners/about-self-hosted-runners#self-hosted-runner-security-with-public-repositories).
+> ⚠️ **`reuse: stop` (warm pools) makes this worse.** With reuse, a runner's disk carries over between jobs, so a later job can read a previous job's residue (checked-out code, caches, credentials written to disk). Only use `reuse: stop` for a **single trusted repository's** CI. Never combine it with public-repo / untrusted-PR workloads. The default `reuse: terminate` gives every job a fresh instance.
+
## License Summary
This code is made available under the [MIT license](LICENSE).
diff --git a/action.yml b/action.yml
index 876ec3e3..d771ff29 100644
--- a/action.yml
+++ b/action.yml
@@ -76,6 +76,39 @@ inputs:
'ec2-instance-ids' output and a warning is logged.
required: false
default: 'false'
+ reuse:
+ description: >-
+ Instance lifecycle. 'terminate' (default) launches a fresh instance on
+ start and terminates it on stop. 'stop' enables warm pools: start
+ reuses a stopped pool instance (or cold-launches into the pool), and
+ stop stops the instance for reuse instead of terminating it. Set the
+ same value on both the start and stop steps.
+ WARNING: reused instances retain the previous job's disk state — safe
+ for a single trusted repo's CI, UNSAFE for public/untrusted-PR
+ workloads. See the security section in the README.
+ required: false
+ default: 'terminate'
+ reuse-pool-tag:
+ description: >-
+ Used with reuse:stop. Pool identity — instances are interchangeable
+ within a pool tag (default 'default'). Use distinct tags for distinct
+ instance shapes (e.g. 'ci-medium', 'ci-large').
+ required: false
+ default: 'default'
+ reuse-max-cycles:
+ description: >-
+ Used with reuse:stop. Recycle (terminate instead of stop) a pool
+ instance after it has served this many jobs, so state doesn't
+ accumulate forever (default 20).
+ required: false
+ default: '20'
+ reaper-stopped-max-age:
+ description: >-
+ Used with the 'cleanup' mode. Terminate stopped pool instances older
+ than this many minutes (default 1440 = 24h) so idle pools drain
+ instead of accruing EBS cost.
+ required: false
+ default: '1440'
label:
description: >-
Name of the unique label assigned to the runner.
diff --git a/dist/index.js b/dist/index.js
index d7d8401f..9fb4e0cf 100644
--- a/dist/index.js
+++ b/dist/index.js
@@ -104656,6 +104656,10 @@ const {
GetConsoleOutputCommand,
RunInstancesCommand,
TerminateInstancesCommand,
+ StartInstancesCommand,
+ StopInstancesCommand,
+ CreateTagsCommand,
+ ModifyInstanceAttributeCommand,
AssociateAddressCommand,
waitUntilInstanceRunning,
} = __nccwpck_require__(5193);
@@ -104825,6 +104829,29 @@ const REPO_TAG_KEY = 'ec2-github-runner:repository';
const LABEL_TAG_KEY = 'ec2-github-runner:label';
const STARTED_AT_TAG_KEY = 'ec2-github-runner:started-at';
+// Warm-pool tags (reuse: stop). `pool` marks a stop/start-reusable instance
+// and its interchangeability group; `cycles` counts how many jobs it has
+// served so it can be recycled at reuse-max-cycles.
+const POOL_TAG_KEY = 'ec2-github-runner:pool';
+const CYCLES_TAG_KEY = 'ec2-github-runner:cycles';
+
+// Shared bootstrap shell helpers, emitted into any shell that phones home:
+// derive instance identity from IMDSv2 and tag the bootstrap phase (best-
+// effort — needs ec2:CreateTags, degrades silently otherwise).
+const PHONE_HOME_HELPERS = [
+ 'gh_runner_imds() {',
+ ' local token',
+ ' token=$(curl -fsS -X PUT "http://169.254.169.254/latest/api/token" -H "X-aws-ec2-metadata-token-ttl-seconds: 120" 2>/dev/null || true)',
+ ' curl -fsS -H "X-aws-ec2-metadata-token: $token" "http://169.254.169.254/latest/meta-data/$1" 2>/dev/null || true',
+ '}',
+ 'GH_RUNNER_IID=$(gh_runner_imds instance-id)',
+ 'GH_RUNNER_REGION=$(gh_runner_imds placement/region)',
+ 'gh_runner_phone_home() {',
+ ' [ -n "$GH_RUNNER_IID" ] && [ -n "$GH_RUNNER_REGION" ] || return 0',
+ ` aws ec2 create-tags --region "$GH_RUNNER_REGION" --resources "$GH_RUNNER_IID" --tags "Key=${BOOTSTRAP_TAG_KEY},Value=$1" >/dev/null 2>&1 || true`,
+ '}',
+];
+
// Console-output capture caps: keep the printed tail useful but bounded so a
// runaway boot log can't flood the Actions run output.
const CONSOLE_TAIL_LINES = 200;
@@ -104966,6 +104993,125 @@ function buildRootDeviceMapping(image, opts = {}) {
return [{ DeviceName: rootDev, Ebs: ebs }];
}
+// Warm-pool (reuse: stop) bootstrap. The expensive setup — deps, runner
+// user, runner tarball download+verify — runs once (idempotent). A per-boot
+// systemd service (gh-runner.service) then re-registers and runs a fresh
+// ephemeral job on EVERY boot, reading the current registration token /
+// label / repo from the instance's IMDS user-data. On a warm start the
+// action rewrites that user-data (ModifyInstanceAttribute) with a fresh
+// token, so the token never lives in a readable tag and is only exposed via
+// IMDS to the instance itself. See warmStartInstance().
+function buildReusableUserData({ runnerVersion, owner, repo, label, githubRegistrationToken, shaX64, shaArm64, ttlLines, preRunnerLines }) {
+ const repoUrl = `https://github.com/${owner}/${repo}`;
+
+ // The per-boot re-registration script, written to /opt on the instance.
+ // Reads the current job params from IMDS user-data (rewritten per warm
+ // start), clears any prior registration, then config + run as the runner
+ // user. Phone-home tags configuring/registered/failed for diagnostics.
+ const registerScript = [
+ '#!/bin/bash',
+ 'set -euo pipefail',
+ ...PHONE_HOME_HELPERS,
+ 'IMDS_TOKEN=$(curl -fsS -X PUT "http://169.254.169.254/latest/api/token" -H "X-aws-ec2-metadata-token-ttl-seconds: 120" 2>/dev/null || true)',
+ 'UD=$(curl -fsS -H "X-aws-ec2-metadata-token: $IMDS_TOKEN" "http://169.254.169.254/latest/user-data" 2>/dev/null || true)',
+ 'GH_TOKEN=$(printf \'%s\\n\' "$UD" | sed -n "s/^GH_TOKEN=\'\\(.*\\)\'$/\\1/p" | head -n1)',
+ 'GH_LABEL=$(printf \'%s\\n\' "$UD" | sed -n "s/^GH_LABEL=\'\\(.*\\)\'$/\\1/p" | head -n1)',
+ 'GH_REPO_URL=$(printf \'%s\\n\' "$UD" | sed -n "s#^GH_REPO_URL=\'\\(.*\\)\'$#\\1#p" | head -n1)',
+ 'cd /home/runner/actions-runner',
+ 'rm -f .runner .credentials .credentials_rsaparams',
+ 'GH_RUNNER_STEP=configuring',
+ 'trap \'gh_runner_phone_home "failed:${GH_RUNNER_STEP}"\' ERR',
+ 'gh_runner_phone_home configuring',
+ 'sudo -u runner -H env DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=1 ./config.sh --url "$GH_REPO_URL" --token "$GH_TOKEN" --labels "$GH_LABEL" --ephemeral --unattended --disableupdate',
+ 'GH_RUNNER_STEP=registered',
+ 'gh_runner_phone_home registered',
+ 'sudo -u runner -H ./run.sh',
+ ];
+
+ const serviceUnit = [
+ '[Unit]',
+ 'Description=GitHub Actions ephemeral runner (warm pool)',
+ 'After=network-online.target',
+ 'Wants=network-online.target',
+ '[Service]',
+ 'Type=simple',
+ 'ExecStart=/opt/gh-runner-register.sh',
+ '[Install]',
+ 'WantedBy=multi-user.target',
+ ];
+
+ return [
+ '#!/bin/bash',
+ 'set -euo pipefail',
+ '',
+ '# --- ec2-github-runner: warm-pool bootstrap (reuse: stop) -----------',
+ ...PHONE_HOME_HELPERS,
+ 'GH_RUNNER_STEP=preparing',
+ "trap 'gh_runner_phone_home \"failed:${GH_RUNNER_STEP}\"' ERR",
+ '',
+ ...ttlLines,
+ '# One-time setup (idempotent across warm restarts).',
+ 'GH_RUNNER_STEP=preparing',
+ 'gh_runner_phone_home preparing',
+ 'mount -o remount,size=1G /tmp || true',
+ 'GH_RUNNER_STEP=installing',
+ 'gh_runner_phone_home installing',
+ 'yum install -y libicu make sudo',
+ 'GH_RUNNER_STEP=creating-user',
+ 'gh_runner_phone_home creating-user',
+ 'if ! id runner >/dev/null 2>&1; then',
+ ' useradd -m -s /bin/bash runner',
+ 'fi',
+ '',
+ ...preRunnerLines,
+ '# Download + verify the runner once (skipped if already present).',
+ 'GH_RUNNER_STEP=downloading',
+ 'gh_runner_phone_home downloading',
+ 'trap - ERR',
+ "sudo -u runner -H bash <<'RUNNER_DOWNLOAD'",
+ 'set -euo pipefail',
+ 'cd "$HOME"',
+ 'mkdir -p actions-runner && cd actions-runner',
+ 'if [ ! -x ./run.sh ]; then',
+ ' case "$(uname -m)" in',
+ ' aarch64) RUNNER_ARCH="arm64" ;;',
+ ' amd64|x86_64) RUNNER_ARCH="x64" ;;',
+ ' *) echo "unsupported arch: $(uname -m)" >&2; exit 1 ;;',
+ ' esac',
+ ` RUNNER_VERSION="${runnerVersion}"`,
+ ' TARBALL="actions-runner-linux-${RUNNER_ARCH}-${RUNNER_VERSION}.tar.gz"',
+ ' BASE="https://github.com/actions/runner/releases/download/v${RUNNER_VERSION}"',
+ ' curl -fsSLo "$TARBALL" "$BASE/$TARBALL"',
+ ' case "$RUNNER_ARCH" in',
+ ` x64) EXPECTED_SHA="${shaX64}" ;;`,
+ ` arm64) EXPECTED_SHA="${shaArm64}" ;;`,
+ ' *) echo "no checksum for arch $RUNNER_ARCH" >&2; exit 1 ;;',
+ ' esac',
+ ' echo "$EXPECTED_SHA $TARBALL" | sha256sum -c -',
+ ' tar xzf "$TARBALL"',
+ 'fi',
+ 'RUNNER_DOWNLOAD',
+ '',
+ '# Current job params. Rewritten per warm start via ModifyInstanceAttribute;',
+ '# the per-boot service reads them back from IMDS user-data.',
+ `GH_TOKEN='${githubRegistrationToken}'`,
+ `GH_LABEL='${label}'`,
+ `GH_REPO_URL='${repoUrl}'`,
+ '',
+ '# Per-boot re-registration script + systemd service.',
+ "cat >/opt/gh-runner-register.sh <<'GH_REGISTER_SCRIPT'",
+ ...registerScript,
+ 'GH_REGISTER_SCRIPT',
+ 'chmod +x /opt/gh-runner-register.sh',
+ "cat >/etc/systemd/system/gh-runner.service <<'GH_RUNNER_SERVICE'",
+ ...serviceUnit,
+ 'GH_RUNNER_SERVICE',
+ 'systemctl daemon-reload',
+ 'systemctl enable --now gh-runner.service',
+ '',
+ ].join('\n');
+}
+
// Build the cloud-init user-data bootstrap script.
//
// Design notes (fix-forward after ec2-github-runner#18/#19/#20):
@@ -104996,7 +105142,7 @@ 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, preRunnerScript }) {
+function buildUserData({ runnerVersion, owner, repo, label, githubRegistrationToken, shaX64, shaArm64, maxLifetimeMinutes, preRunnerScript, reuse }) {
// 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
@@ -105027,29 +105173,19 @@ function buildUserData({ runnerVersion, owner, repo, label, githubRegistrationTo
]
: [];
- // Shared shell helpers, emitted verbatim into both the outer (root) and
- // inner (runner-user) shells — each shell re-derives instance identity
- // from IMDS so it can keep phoning home independently.
- const phoneHomeHelpers = [
- 'gh_runner_imds() {',
- ' local token',
- ' token=$(curl -fsS -X PUT "http://169.254.169.254/latest/api/token" -H "X-aws-ec2-metadata-token-ttl-seconds: 120" 2>/dev/null || true)',
- ' curl -fsS -H "X-aws-ec2-metadata-token: $token" "http://169.254.169.254/latest/meta-data/$1" 2>/dev/null || true',
- '}',
- 'GH_RUNNER_IID=$(gh_runner_imds instance-id)',
- 'GH_RUNNER_REGION=$(gh_runner_imds placement/region)',
- 'gh_runner_phone_home() {',
- ' [ -n "$GH_RUNNER_IID" ] && [ -n "$GH_RUNNER_REGION" ] || return 0',
- ` aws ec2 create-tags --region "$GH_RUNNER_REGION" --resources "$GH_RUNNER_IID" --tags "Key=${BOOTSTRAP_TAG_KEY},Value=$1" >/dev/null 2>&1 || true`,
- '}',
- ];
+ // reuse: stop needs a per-boot re-registration hook instead of a one-shot
+ // config+run — the runner is set up once but re-registers on every warm
+ // start. Kept in a separate builder so the default path stays untouched.
+ if (reuse === 'stop') {
+ return buildReusableUserData({ runnerVersion, owner, repo, label, githubRegistrationToken, shaX64, shaArm64, ttlLines, preRunnerLines });
+ }
return [
'#!/bin/bash',
'set -euo pipefail',
'',
'# --- ec2-github-runner: bootstrap diagnostics (phone-home) ----------',
- ...phoneHomeHelpers,
+ ...PHONE_HOME_HELPERS,
'GH_RUNNER_STEP=preparing',
"trap 'gh_runner_phone_home \"failed:${GH_RUNNER_STEP}\"' ERR",
'',
@@ -105076,7 +105212,7 @@ function buildUserData({ runnerVersion, owner, repo, label, githubRegistrationTo
'trap - ERR',
"sudo -u runner -H bash <<'RUNNER_BOOTSTRAP'",
'set -euo pipefail',
- ...phoneHomeHelpers,
+ ...PHONE_HOME_HELPERS,
'GH_RUNNER_STEP=downloading',
"trap 'gh_runner_phone_home \"failed:${GH_RUNNER_STEP}\"' ERR",
'gh_runner_phone_home downloading',
@@ -105130,7 +105266,7 @@ function buildTagSpecifications(label, startedAtIso) {
const owner = config.githubContext.owner;
const repo = config.githubContext.repo;
const userTags = config.input.awsResourceTags || [];
- const signatureKeys = new Set([MANAGED_TAG_KEY, REPO_TAG_KEY, LABEL_TAG_KEY, STARTED_AT_TAG_KEY]);
+ const signatureKeys = new Set([MANAGED_TAG_KEY, REPO_TAG_KEY, LABEL_TAG_KEY, STARTED_AT_TAG_KEY, POOL_TAG_KEY, CYCLES_TAG_KEY]);
const tags = [
...userTags.filter((t) => !signatureKeys.has(t.Key)),
{ Key: MANAGED_TAG_KEY, Value: 'true' },
@@ -105138,12 +105274,40 @@ function buildTagSpecifications(label, startedAtIso) {
{ Key: LABEL_TAG_KEY, Value: label },
{ Key: STARTED_AT_TAG_KEY, Value: startedAtIso },
];
+ // Warm-pool membership so a cold-launched instance can be reused later.
+ if (config.input.reuse === 'stop') {
+ tags.push({ Key: POOL_TAG_KEY, Value: config.input.reusePoolTag });
+ tags.push({ Key: CYCLES_TAG_KEY, Value: '0' });
+ }
return [
{ ResourceType: 'instance', Tags: tags },
{ ResourceType: 'volume', Tags: tags },
];
}
+// Build the warm-pool (reuse: stop) user-data for a fresh token/label —
+// used by the warm-start path to rewrite a stopped instance's user-data.
+function buildReuseUserData(label, githubRegistrationToken) {
+ const runnerVersion = config.input.runnerVersion;
+ const shaX64 = checksums.lookup('x64', runnerVersion);
+ const shaArm64 = checksums.lookup('arm64', runnerVersion);
+ if (!shaX64 || !shaArm64) {
+ throw new Error(`No SHA-256 entry in src/runner-checksums.js for runner-version ${runnerVersion}.`);
+ }
+ return buildUserData({
+ runnerVersion,
+ owner: config.githubContext.owner,
+ repo: config.githubContext.repo,
+ label,
+ githubRegistrationToken,
+ shaX64,
+ shaArm64,
+ maxLifetimeMinutes: config.input.maxLifetimeMinutes,
+ preRunnerScript: config.input.preRunnerScript,
+ reuse: 'stop',
+ });
+}
+
// 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) {
@@ -105191,6 +105355,7 @@ async function startEc2Instance(label, githubRegistrationToken) {
userData = buildUserData({
runnerVersion, owner, repo, label, githubRegistrationToken, shaX64, shaArm64, maxLifetimeMinutes,
preRunnerScript: config.input.preRunnerScript,
+ reuse: config.input.reuse,
});
}
assertUserDataSize(userData);
@@ -105494,13 +105659,13 @@ async function handleStartFailure(ec2InstanceIds, opts = {}) {
// returned, so a near-miss tag set can never be reaped). Each result
// carries the parsed started-at (ms; falls back to LaunchTime) and label
// the reaper needs to make its decision.
-async function listManagedInstances(repo) {
+async function listManagedInstances(repo, states = ['pending', 'running']) {
const client = ec2Client();
const resp = await client.send(new DescribeInstancesCommand({
Filters: [
{ Name: `tag:${MANAGED_TAG_KEY}`, Values: ['true'] },
{ Name: `tag:${REPO_TAG_KEY}`, Values: [repo] },
- { Name: 'instance-state-name', Values: ['pending', 'running'] },
+ { Name: 'instance-state-name', Values: states },
],
}));
@@ -105523,12 +105688,106 @@ async function listManagedInstances(repo) {
instanceId: instance.InstanceId,
label: tags[LABEL_TAG_KEY] || null,
startedAtMs,
+ state: instance.State ? instance.State.Name : null,
});
}
}
return instances;
}
+// Find one stopped, reusable pool instance matching the full signature +
+// pool tag + instance type + architecture (all required; near-misses are
+// never returned — the safety guarantee). Returns { instanceId, subnetId }
+// or null when the pool is empty.
+async function findStoppedPoolInstance({ repo, poolTag, instanceType, architecture }) {
+ const client = ec2Client();
+ const amiArch = AMI_ARCH_BY_INPUT[architecture];
+ const resp = await client.send(new DescribeInstancesCommand({
+ Filters: [
+ { Name: `tag:${MANAGED_TAG_KEY}`, Values: ['true'] },
+ { Name: `tag:${REPO_TAG_KEY}`, Values: [repo] },
+ { Name: `tag:${POOL_TAG_KEY}`, Values: [poolTag] },
+ { Name: 'instance-state-name', Values: ['stopped'] },
+ { Name: 'instance-type', Values: [instanceType] },
+ ],
+ }));
+
+ for (const reservation of resp.Reservations || []) {
+ for (const instance of reservation.Instances || []) {
+ const tags = {};
+ for (const tag of instance.Tags || []) {
+ tags[tag.Key] = tag.Value;
+ }
+ // Strict client-side re-validation of the full signature.
+ if (tags[MANAGED_TAG_KEY] !== 'true' || tags[REPO_TAG_KEY] !== repo || tags[POOL_TAG_KEY] !== poolTag) {
+ continue;
+ }
+ if (instance.InstanceType !== instanceType || (amiArch && instance.Architecture && instance.Architecture !== amiArch)) {
+ continue;
+ }
+ return { instanceId: instance.InstanceId, subnetId: instance.SubnetId };
+ }
+ }
+ return null;
+}
+
+// Warm-start a stopped pool instance: rewrite its user-data with the fresh
+// registration token/label (so the per-boot service re-registers), update
+// its label + started-at tags, then StartInstances.
+async function warmStartInstance(instanceId, { userData, label }) {
+ const client = ec2Client();
+ await client.send(new ModifyInstanceAttributeCommand({
+ InstanceId: instanceId,
+ UserData: { Value: Buffer.from(userData).toString('base64') },
+ }));
+ await client.send(new CreateTagsCommand({
+ Resources: [instanceId],
+ Tags: [
+ { Key: LABEL_TAG_KEY, Value: label },
+ { Key: STARTED_AT_TAG_KEY, Value: new Date().toISOString() },
+ ],
+ }));
+ await withRetry('start_instance', () => client.send(new StartInstancesCommand({ InstanceIds: [instanceId] })));
+ log.info('warm_start', { instance_id: instanceId, label });
+ core.info(`Reused warm-pool EC2 instance ${instanceId}`);
+}
+
+// Stop (not terminate) an instance so it can be reused from the pool.
+async function stopInstanceById(instanceId) {
+ const client = ec2Client();
+ await withRetry('stop_instance', () => client.send(new StopInstancesCommand({ InstanceIds: [instanceId] })));
+ log.info('stop_instance', { instance_id: instanceId });
+ core.info(`AWS EC2 instance ${instanceId} is stopped (warm pool)`);
+}
+
+// Read the reuse-cycle counter tag for an instance (0 when absent).
+async function getInstanceCycles(instanceId) {
+ const client = ec2Client();
+ try {
+ const resp = await client.send(new DescribeTagsCommand({
+ Filters: [
+ { Name: 'resource-id', Values: [instanceId] },
+ { Name: 'key', Values: [CYCLES_TAG_KEY] },
+ ],
+ }));
+ const tag = (resp.Tags || []).find((t) => t.Key === CYCLES_TAG_KEY);
+ const n = tag ? parseInt(tag.Value, 10) : 0;
+ return Number.isFinite(n) ? n : 0;
+ } catch (error) {
+ log.debug('instance_cycles', { instance_id: instanceId, error: error.name, message: error.message });
+ return 0;
+ }
+}
+
+// Set the reuse-cycle counter tag.
+async function setInstanceCycles(instanceId, cycles) {
+ const client = ec2Client();
+ await client.send(new CreateTagsCommand({
+ Resources: [instanceId],
+ Tags: [{ Key: CYCLES_TAG_KEY, Value: String(cycles) }],
+ }));
+}
+
module.exports = {
startEc2Instance,
terminateInstanceById,
@@ -105538,6 +105797,12 @@ module.exports = {
getConsoleOutputTail,
handleStartFailure,
listManagedInstances,
+ findStoppedPoolInstance,
+ warmStartInstance,
+ stopInstanceById,
+ getInstanceCycles,
+ setInstanceCycles,
+ buildReuseUserData,
// Exported for unit testing.
classifyRunError,
matchAmiArchitecture,
@@ -105556,6 +105821,8 @@ module.exports = {
REPO_TAG_KEY,
LABEL_TAG_KEY,
STARTED_AT_TAG_KEY,
+ POOL_TAG_KEY,
+ CYCLES_TAG_KEY,
};
@@ -105576,15 +105843,26 @@ const REAP_GRACE_MINUTES = 15;
// GitHub runner (or null if none is registered). Pure function — no I/O —
// so the full decision matrix is unit-testable.
//
+// stopped, older than stopped-max-age -> reap (drain idle warm pool)
+// stopped, within stopped-max-age -> skip
// within grace period -> skip (protect in-flight starts)
// no runner registered -> reap (leaked instance)
// runner busy -> skip (job in progress, any age)
// runner idle, older than max -> reap + deregister
// runner idle, within max age -> skip
function decideReap(instance, runner, opts) {
- const { nowMs, maxAgeMinutes, graceMinutes } = opts;
+ const { nowMs, maxAgeMinutes, graceMinutes, stoppedMaxAgeMinutes } = opts;
const ageMinutes = instance.startedAtMs != null ? (nowMs - instance.startedAtMs) / 60000 : Infinity;
+ // Stopped warm-pool instances: drain those older than the stopped max-age
+ // so pools don't accrete EBS cost forever. No runner check — it's stopped.
+ if (instance.state === 'stopped') {
+ if (stoppedMaxAgeMinutes != null && ageMinutes > stoppedMaxAgeMinutes) {
+ return { action: 'reap', reason: 'stopped-past-max-age', deregister: false };
+ }
+ return { action: 'skip', reason: 'stopped-within-max-age', deregister: false };
+ }
+
if (ageMinutes < graceMinutes) {
return { action: 'skip', reason: 'within-grace-period', deregister: false };
}
@@ -105615,6 +105893,7 @@ async function runReaper(deps, opts = {}) {
const { listManagedInstances, getRunnerByLabel, terminateInstance, deregisterRunner, now } = deps;
const maxAgeMinutes = opts.maxAgeMinutes;
const graceMinutes = opts.graceMinutes ?? REAP_GRACE_MINUTES;
+ const stoppedMaxAgeMinutes = opts.stoppedMaxAgeMinutes;
const dryRun = !!opts.dryRun;
const instances = await listManagedInstances();
@@ -105624,8 +105903,9 @@ async function runReaper(deps, opts = {}) {
let skipped = 0;
for (const instance of instances) {
- const runner = instance.label ? await getRunnerByLabel(instance.label) : null;
- const decision = decideReap(instance, runner, { nowMs, maxAgeMinutes, graceMinutes });
+ // Stopped instances have no live runner to cross-check.
+ const runner = (instance.state !== 'stopped' && instance.label) ? await getRunnerByLabel(instance.label) : null;
+ const decision = decideReap(instance, runner, { nowMs, maxAgeMinutes, graceMinutes, stoppedMaxAgeMinutes });
const row = {
instanceId: instance.instanceId,
label: instance.label,
@@ -105733,6 +106013,10 @@ class Config {
allowPartial: core.getInput('allow-partial') || 'false',
preRunnerScript: core.getInput('pre-runner-script'),
userDataTemplate: core.getInput('user-data-template'),
+ reuse: core.getInput('reuse') || 'terminate',
+ reusePoolTag: core.getInput('reuse-pool-tag') || 'default',
+ reuseMaxCycles: core.getInput('reuse-max-cycles') || '20',
+ reaperStoppedMaxAge: core.getInput('reaper-stopped-max-age') || '1440',
iamRoleName: core.getInput('iam-role-name'),
runnerVersion: core.getInput('runner-version') || '2.335.1',
architecture: core.getInput('architecture') || 'x64',
@@ -105787,12 +106071,14 @@ class Config {
this.validateArchitectureInputs();
this.validateCountInput();
this.validateBootstrapInputs();
+ this.validateReuseInputs();
} 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.
if (!this.input.label || (!this.input.ec2InstanceId && !(this.input.ec2InstanceIds && this.input.ec2InstanceIds.length))) {
throw new Error(`Not all the required inputs are provided for the 'stop' mode`);
}
+ this.validateReuseInputs();
} else if (this.input.mode === 'cleanup') {
// The reaper needs only the github-token (validated above) and
// operates on the current repository; max-age-minutes and dry-run
@@ -105873,6 +106159,16 @@ class Config {
}
}
+ // Validate the warm-pool reuse inputs (start + stop).
+ validateReuseInputs() {
+ if (!['terminate', 'stop'].includes(this.input.reuse)) {
+ throw new Error(`'reuse' must be one of: terminate, stop`);
+ }
+ if (!(/^[0-9]+$/.test(this.input.reuseMaxCycles) && Number(this.input.reuseMaxCycles) >= 1)) {
+ throw new Error(`'reuse-max-cycles' must be a positive integer`);
+ }
+ }
+
// Validate the multi-runner batch size.
validateCountInput() {
if (!(/^[0-9]+$/.test(this.input.count) && Number(this.input.count) >= 1)) {
@@ -111277,6 +111573,7 @@ const config = __nccwpck_require__(1283);
const log = __nccwpck_require__(7223);
const { waitForRunnerReady } = __nccwpck_require__(8644);
const { runReaper, renderCleanupSummary, REAP_GRACE_MINUTES } = __nccwpck_require__(5541);
+const { parseCsv } = __nccwpck_require__(5804);
const core = __nccwpck_require__(7484);
// Write directly to the $GITHUB_OUTPUT file. The bundled @actions/core
@@ -111304,13 +111601,49 @@ function setOutput(label, placement) {
core.setOutput('market-type-used', marketType);
}
+// Warm-pool fast path: reuse a stopped pool instance (count 1 only). Returns
+// a placement, or null to fall back to a cold launch (empty pool or a failed
+// start — the cold launch then joins the pool).
+async function tryWarmStart(label, githubRegistrationToken) {
+ const repo = `${config.githubContext.owner}/${config.githubContext.repo}`;
+ const instanceType = parseCsv(config.input.ec2InstanceType)[0];
+ const pool = await aws.findStoppedPoolInstance({
+ repo,
+ poolTag: config.input.reusePoolTag,
+ instanceType,
+ architecture: config.input.architecture,
+ });
+ if (!pool) {
+ log.info('warm_start', { outcome: 'pool_empty', pool: config.input.reusePoolTag });
+ return null;
+ }
+ try {
+ const userData = aws.buildReuseUserData(label, githubRegistrationToken);
+ await aws.warmStartInstance(pool.instanceId, { userData, label });
+ return { instanceIds: [pool.instanceId], instanceType, subnetId: pool.subnetId, marketType: 'reused' };
+ } catch (error) {
+ log.warn('warm_start', { instance_id: pool.instanceId, error: error.name, message: error.message });
+ core.warning(`Warm start of ${pool.instanceId} failed (${error.message}); cold-launching instead`);
+ return null;
+ }
+}
+
async function start() {
core.startGroup('start-runner');
try {
log.debug('start_inputs', config.input); // sanitized inside log.js
const label = config.generateUniqueLabel();
const githubRegistrationToken = await gh.getRegistrationToken();
- const placement = await aws.startEc2Instance(label, githubRegistrationToken);
+
+ // Warm pool: reuse a stopped instance when reuse:stop and count 1;
+ // otherwise (or on empty pool / failed reuse) cold-launch.
+ let placement = null;
+ if (config.input.reuse === 'stop' && Number(config.input.count) === 1) {
+ placement = await tryWarmStart(label, githubRegistrationToken);
+ }
+ if (!placement) {
+ placement = await aws.startEc2Instance(label, githubRegistrationToken);
+ }
const instanceIds = placement.instanceIds;
setOutput(label, placement);
for (const id of instanceIds) {
@@ -111350,14 +111683,29 @@ async function stop() {
? config.input.ec2InstanceIds
: [config.input.ec2InstanceId];
+ const reuse = config.input.reuse === 'stop';
+ const maxCycles = Number(config.input.reuseMaxCycles);
for (const id of instanceIds) {
try {
- await aws.terminateInstanceById(id);
+ if (reuse) {
+ // Warm pool: stop for reuse until the instance has served
+ // reuse-max-cycles jobs, then recycle it (terminate).
+ const cycles = await aws.getInstanceCycles(id);
+ if (cycles + 1 >= maxCycles) {
+ log.info('reuse', { instance_id: id, action: 'terminate', reason: 'max_cycles_reached', cycles });
+ await aws.terminateInstanceById(id);
+ } else {
+ await aws.setInstanceCycles(id, cycles + 1);
+ await aws.stopInstanceById(id);
+ }
+ } else {
+ await aws.terminateInstanceById(id);
+ }
} catch (error) {
if (error.name && error.name.includes('NotFound')) {
log.info('terminate_instance', { instance_id: id, skipped: true, reason: 'already_gone' });
} else {
- failures.push({ step: `terminate_instance:${id}`, error: error.name, message: error.message });
+ failures.push({ step: `${reuse ? 'stop' : 'terminate'}_instance:${id}`, error: error.name, message: error.message });
}
}
}
@@ -111404,7 +111752,8 @@ async function cleanup() {
const summary = await runReaper(
{
- listManagedInstances: () => aws.listManagedInstances(repo),
+ // Include stopped instances so idle warm-pool instances are drained.
+ listManagedInstances: () => aws.listManagedInstances(repo, ['pending', 'running', 'stopped']),
getRunnerByLabel: (label) => gh.getRunner(label),
terminateInstance: (id) => aws.terminateInstanceById(id),
deregisterRunner: (runnerId) => gh.deregisterRunner(runnerId),
@@ -111413,6 +111762,7 @@ async function cleanup() {
{
maxAgeMinutes: Number(config.input.maxAgeMinutes),
graceMinutes: REAP_GRACE_MINUTES,
+ stoppedMaxAgeMinutes: Number(config.input.reaperStoppedMaxAge),
dryRun,
},
);
diff --git a/src/aws.js b/src/aws.js
index 84d7e3b3..8aa1c4da 100644
--- a/src/aws.js
+++ b/src/aws.js
@@ -6,6 +6,10 @@ const {
GetConsoleOutputCommand,
RunInstancesCommand,
TerminateInstancesCommand,
+ StartInstancesCommand,
+ StopInstancesCommand,
+ CreateTagsCommand,
+ ModifyInstanceAttributeCommand,
AssociateAddressCommand,
waitUntilInstanceRunning,
} = require('@aws-sdk/client-ec2');
@@ -175,6 +179,29 @@ const REPO_TAG_KEY = 'ec2-github-runner:repository';
const LABEL_TAG_KEY = 'ec2-github-runner:label';
const STARTED_AT_TAG_KEY = 'ec2-github-runner:started-at';
+// Warm-pool tags (reuse: stop). `pool` marks a stop/start-reusable instance
+// and its interchangeability group; `cycles` counts how many jobs it has
+// served so it can be recycled at reuse-max-cycles.
+const POOL_TAG_KEY = 'ec2-github-runner:pool';
+const CYCLES_TAG_KEY = 'ec2-github-runner:cycles';
+
+// Shared bootstrap shell helpers, emitted into any shell that phones home:
+// derive instance identity from IMDSv2 and tag the bootstrap phase (best-
+// effort — needs ec2:CreateTags, degrades silently otherwise).
+const PHONE_HOME_HELPERS = [
+ 'gh_runner_imds() {',
+ ' local token',
+ ' token=$(curl -fsS -X PUT "http://169.254.169.254/latest/api/token" -H "X-aws-ec2-metadata-token-ttl-seconds: 120" 2>/dev/null || true)',
+ ' curl -fsS -H "X-aws-ec2-metadata-token: $token" "http://169.254.169.254/latest/meta-data/$1" 2>/dev/null || true',
+ '}',
+ 'GH_RUNNER_IID=$(gh_runner_imds instance-id)',
+ 'GH_RUNNER_REGION=$(gh_runner_imds placement/region)',
+ 'gh_runner_phone_home() {',
+ ' [ -n "$GH_RUNNER_IID" ] && [ -n "$GH_RUNNER_REGION" ] || return 0',
+ ` aws ec2 create-tags --region "$GH_RUNNER_REGION" --resources "$GH_RUNNER_IID" --tags "Key=${BOOTSTRAP_TAG_KEY},Value=$1" >/dev/null 2>&1 || true`,
+ '}',
+];
+
// Console-output capture caps: keep the printed tail useful but bounded so a
// runaway boot log can't flood the Actions run output.
const CONSOLE_TAIL_LINES = 200;
@@ -316,6 +343,125 @@ function buildRootDeviceMapping(image, opts = {}) {
return [{ DeviceName: rootDev, Ebs: ebs }];
}
+// Warm-pool (reuse: stop) bootstrap. The expensive setup — deps, runner
+// user, runner tarball download+verify — runs once (idempotent). A per-boot
+// systemd service (gh-runner.service) then re-registers and runs a fresh
+// ephemeral job on EVERY boot, reading the current registration token /
+// label / repo from the instance's IMDS user-data. On a warm start the
+// action rewrites that user-data (ModifyInstanceAttribute) with a fresh
+// token, so the token never lives in a readable tag and is only exposed via
+// IMDS to the instance itself. See warmStartInstance().
+function buildReusableUserData({ runnerVersion, owner, repo, label, githubRegistrationToken, shaX64, shaArm64, ttlLines, preRunnerLines }) {
+ const repoUrl = `https://github.com/${owner}/${repo}`;
+
+ // The per-boot re-registration script, written to /opt on the instance.
+ // Reads the current job params from IMDS user-data (rewritten per warm
+ // start), clears any prior registration, then config + run as the runner
+ // user. Phone-home tags configuring/registered/failed for diagnostics.
+ const registerScript = [
+ '#!/bin/bash',
+ 'set -euo pipefail',
+ ...PHONE_HOME_HELPERS,
+ 'IMDS_TOKEN=$(curl -fsS -X PUT "http://169.254.169.254/latest/api/token" -H "X-aws-ec2-metadata-token-ttl-seconds: 120" 2>/dev/null || true)',
+ 'UD=$(curl -fsS -H "X-aws-ec2-metadata-token: $IMDS_TOKEN" "http://169.254.169.254/latest/user-data" 2>/dev/null || true)',
+ 'GH_TOKEN=$(printf \'%s\\n\' "$UD" | sed -n "s/^GH_TOKEN=\'\\(.*\\)\'$/\\1/p" | head -n1)',
+ 'GH_LABEL=$(printf \'%s\\n\' "$UD" | sed -n "s/^GH_LABEL=\'\\(.*\\)\'$/\\1/p" | head -n1)',
+ 'GH_REPO_URL=$(printf \'%s\\n\' "$UD" | sed -n "s#^GH_REPO_URL=\'\\(.*\\)\'$#\\1#p" | head -n1)',
+ 'cd /home/runner/actions-runner',
+ 'rm -f .runner .credentials .credentials_rsaparams',
+ 'GH_RUNNER_STEP=configuring',
+ 'trap \'gh_runner_phone_home "failed:${GH_RUNNER_STEP}"\' ERR',
+ 'gh_runner_phone_home configuring',
+ 'sudo -u runner -H env DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=1 ./config.sh --url "$GH_REPO_URL" --token "$GH_TOKEN" --labels "$GH_LABEL" --ephemeral --unattended --disableupdate',
+ 'GH_RUNNER_STEP=registered',
+ 'gh_runner_phone_home registered',
+ 'sudo -u runner -H ./run.sh',
+ ];
+
+ const serviceUnit = [
+ '[Unit]',
+ 'Description=GitHub Actions ephemeral runner (warm pool)',
+ 'After=network-online.target',
+ 'Wants=network-online.target',
+ '[Service]',
+ 'Type=simple',
+ 'ExecStart=/opt/gh-runner-register.sh',
+ '[Install]',
+ 'WantedBy=multi-user.target',
+ ];
+
+ return [
+ '#!/bin/bash',
+ 'set -euo pipefail',
+ '',
+ '# --- ec2-github-runner: warm-pool bootstrap (reuse: stop) -----------',
+ ...PHONE_HOME_HELPERS,
+ 'GH_RUNNER_STEP=preparing',
+ "trap 'gh_runner_phone_home \"failed:${GH_RUNNER_STEP}\"' ERR",
+ '',
+ ...ttlLines,
+ '# One-time setup (idempotent across warm restarts).',
+ 'GH_RUNNER_STEP=preparing',
+ 'gh_runner_phone_home preparing',
+ 'mount -o remount,size=1G /tmp || true',
+ 'GH_RUNNER_STEP=installing',
+ 'gh_runner_phone_home installing',
+ 'yum install -y libicu make sudo',
+ 'GH_RUNNER_STEP=creating-user',
+ 'gh_runner_phone_home creating-user',
+ 'if ! id runner >/dev/null 2>&1; then',
+ ' useradd -m -s /bin/bash runner',
+ 'fi',
+ '',
+ ...preRunnerLines,
+ '# Download + verify the runner once (skipped if already present).',
+ 'GH_RUNNER_STEP=downloading',
+ 'gh_runner_phone_home downloading',
+ 'trap - ERR',
+ "sudo -u runner -H bash <<'RUNNER_DOWNLOAD'",
+ 'set -euo pipefail',
+ 'cd "$HOME"',
+ 'mkdir -p actions-runner && cd actions-runner',
+ 'if [ ! -x ./run.sh ]; then',
+ ' case "$(uname -m)" in',
+ ' aarch64) RUNNER_ARCH="arm64" ;;',
+ ' amd64|x86_64) RUNNER_ARCH="x64" ;;',
+ ' *) echo "unsupported arch: $(uname -m)" >&2; exit 1 ;;',
+ ' esac',
+ ` RUNNER_VERSION="${runnerVersion}"`,
+ ' TARBALL="actions-runner-linux-${RUNNER_ARCH}-${RUNNER_VERSION}.tar.gz"',
+ ' BASE="https://github.com/actions/runner/releases/download/v${RUNNER_VERSION}"',
+ ' curl -fsSLo "$TARBALL" "$BASE/$TARBALL"',
+ ' case "$RUNNER_ARCH" in',
+ ` x64) EXPECTED_SHA="${shaX64}" ;;`,
+ ` arm64) EXPECTED_SHA="${shaArm64}" ;;`,
+ ' *) echo "no checksum for arch $RUNNER_ARCH" >&2; exit 1 ;;',
+ ' esac',
+ ' echo "$EXPECTED_SHA $TARBALL" | sha256sum -c -',
+ ' tar xzf "$TARBALL"',
+ 'fi',
+ 'RUNNER_DOWNLOAD',
+ '',
+ '# Current job params. Rewritten per warm start via ModifyInstanceAttribute;',
+ '# the per-boot service reads them back from IMDS user-data.',
+ `GH_TOKEN='${githubRegistrationToken}'`,
+ `GH_LABEL='${label}'`,
+ `GH_REPO_URL='${repoUrl}'`,
+ '',
+ '# Per-boot re-registration script + systemd service.',
+ "cat >/opt/gh-runner-register.sh <<'GH_REGISTER_SCRIPT'",
+ ...registerScript,
+ 'GH_REGISTER_SCRIPT',
+ 'chmod +x /opt/gh-runner-register.sh',
+ "cat >/etc/systemd/system/gh-runner.service <<'GH_RUNNER_SERVICE'",
+ ...serviceUnit,
+ 'GH_RUNNER_SERVICE',
+ 'systemctl daemon-reload',
+ 'systemctl enable --now gh-runner.service',
+ '',
+ ].join('\n');
+}
+
// Build the cloud-init user-data bootstrap script.
//
// Design notes (fix-forward after ec2-github-runner#18/#19/#20):
@@ -346,7 +492,7 @@ 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, preRunnerScript }) {
+function buildUserData({ runnerVersion, owner, repo, label, githubRegistrationToken, shaX64, shaArm64, maxLifetimeMinutes, preRunnerScript, reuse }) {
// 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
@@ -377,29 +523,19 @@ function buildUserData({ runnerVersion, owner, repo, label, githubRegistrationTo
]
: [];
- // Shared shell helpers, emitted verbatim into both the outer (root) and
- // inner (runner-user) shells — each shell re-derives instance identity
- // from IMDS so it can keep phoning home independently.
- const phoneHomeHelpers = [
- 'gh_runner_imds() {',
- ' local token',
- ' token=$(curl -fsS -X PUT "http://169.254.169.254/latest/api/token" -H "X-aws-ec2-metadata-token-ttl-seconds: 120" 2>/dev/null || true)',
- ' curl -fsS -H "X-aws-ec2-metadata-token: $token" "http://169.254.169.254/latest/meta-data/$1" 2>/dev/null || true',
- '}',
- 'GH_RUNNER_IID=$(gh_runner_imds instance-id)',
- 'GH_RUNNER_REGION=$(gh_runner_imds placement/region)',
- 'gh_runner_phone_home() {',
- ' [ -n "$GH_RUNNER_IID" ] && [ -n "$GH_RUNNER_REGION" ] || return 0',
- ` aws ec2 create-tags --region "$GH_RUNNER_REGION" --resources "$GH_RUNNER_IID" --tags "Key=${BOOTSTRAP_TAG_KEY},Value=$1" >/dev/null 2>&1 || true`,
- '}',
- ];
+ // reuse: stop needs a per-boot re-registration hook instead of a one-shot
+ // config+run — the runner is set up once but re-registers on every warm
+ // start. Kept in a separate builder so the default path stays untouched.
+ if (reuse === 'stop') {
+ return buildReusableUserData({ runnerVersion, owner, repo, label, githubRegistrationToken, shaX64, shaArm64, ttlLines, preRunnerLines });
+ }
return [
'#!/bin/bash',
'set -euo pipefail',
'',
'# --- ec2-github-runner: bootstrap diagnostics (phone-home) ----------',
- ...phoneHomeHelpers,
+ ...PHONE_HOME_HELPERS,
'GH_RUNNER_STEP=preparing',
"trap 'gh_runner_phone_home \"failed:${GH_RUNNER_STEP}\"' ERR",
'',
@@ -426,7 +562,7 @@ function buildUserData({ runnerVersion, owner, repo, label, githubRegistrationTo
'trap - ERR',
"sudo -u runner -H bash <<'RUNNER_BOOTSTRAP'",
'set -euo pipefail',
- ...phoneHomeHelpers,
+ ...PHONE_HOME_HELPERS,
'GH_RUNNER_STEP=downloading',
"trap 'gh_runner_phone_home \"failed:${GH_RUNNER_STEP}\"' ERR",
'gh_runner_phone_home downloading',
@@ -480,7 +616,7 @@ function buildTagSpecifications(label, startedAtIso) {
const owner = config.githubContext.owner;
const repo = config.githubContext.repo;
const userTags = config.input.awsResourceTags || [];
- const signatureKeys = new Set([MANAGED_TAG_KEY, REPO_TAG_KEY, LABEL_TAG_KEY, STARTED_AT_TAG_KEY]);
+ const signatureKeys = new Set([MANAGED_TAG_KEY, REPO_TAG_KEY, LABEL_TAG_KEY, STARTED_AT_TAG_KEY, POOL_TAG_KEY, CYCLES_TAG_KEY]);
const tags = [
...userTags.filter((t) => !signatureKeys.has(t.Key)),
{ Key: MANAGED_TAG_KEY, Value: 'true' },
@@ -488,12 +624,40 @@ function buildTagSpecifications(label, startedAtIso) {
{ Key: LABEL_TAG_KEY, Value: label },
{ Key: STARTED_AT_TAG_KEY, Value: startedAtIso },
];
+ // Warm-pool membership so a cold-launched instance can be reused later.
+ if (config.input.reuse === 'stop') {
+ tags.push({ Key: POOL_TAG_KEY, Value: config.input.reusePoolTag });
+ tags.push({ Key: CYCLES_TAG_KEY, Value: '0' });
+ }
return [
{ ResourceType: 'instance', Tags: tags },
{ ResourceType: 'volume', Tags: tags },
];
}
+// Build the warm-pool (reuse: stop) user-data for a fresh token/label —
+// used by the warm-start path to rewrite a stopped instance's user-data.
+function buildReuseUserData(label, githubRegistrationToken) {
+ const runnerVersion = config.input.runnerVersion;
+ const shaX64 = checksums.lookup('x64', runnerVersion);
+ const shaArm64 = checksums.lookup('arm64', runnerVersion);
+ if (!shaX64 || !shaArm64) {
+ throw new Error(`No SHA-256 entry in src/runner-checksums.js for runner-version ${runnerVersion}.`);
+ }
+ return buildUserData({
+ runnerVersion,
+ owner: config.githubContext.owner,
+ repo: config.githubContext.repo,
+ label,
+ githubRegistrationToken,
+ shaX64,
+ shaArm64,
+ maxLifetimeMinutes: config.input.maxLifetimeMinutes,
+ preRunnerScript: config.input.preRunnerScript,
+ reuse: 'stop',
+ });
+}
+
// 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) {
@@ -541,6 +705,7 @@ async function startEc2Instance(label, githubRegistrationToken) {
userData = buildUserData({
runnerVersion, owner, repo, label, githubRegistrationToken, shaX64, shaArm64, maxLifetimeMinutes,
preRunnerScript: config.input.preRunnerScript,
+ reuse: config.input.reuse,
});
}
assertUserDataSize(userData);
@@ -844,13 +1009,13 @@ async function handleStartFailure(ec2InstanceIds, opts = {}) {
// returned, so a near-miss tag set can never be reaped). Each result
// carries the parsed started-at (ms; falls back to LaunchTime) and label
// the reaper needs to make its decision.
-async function listManagedInstances(repo) {
+async function listManagedInstances(repo, states = ['pending', 'running']) {
const client = ec2Client();
const resp = await client.send(new DescribeInstancesCommand({
Filters: [
{ Name: `tag:${MANAGED_TAG_KEY}`, Values: ['true'] },
{ Name: `tag:${REPO_TAG_KEY}`, Values: [repo] },
- { Name: 'instance-state-name', Values: ['pending', 'running'] },
+ { Name: 'instance-state-name', Values: states },
],
}));
@@ -873,12 +1038,106 @@ async function listManagedInstances(repo) {
instanceId: instance.InstanceId,
label: tags[LABEL_TAG_KEY] || null,
startedAtMs,
+ state: instance.State ? instance.State.Name : null,
});
}
}
return instances;
}
+// Find one stopped, reusable pool instance matching the full signature +
+// pool tag + instance type + architecture (all required; near-misses are
+// never returned — the safety guarantee). Returns { instanceId, subnetId }
+// or null when the pool is empty.
+async function findStoppedPoolInstance({ repo, poolTag, instanceType, architecture }) {
+ const client = ec2Client();
+ const amiArch = AMI_ARCH_BY_INPUT[architecture];
+ const resp = await client.send(new DescribeInstancesCommand({
+ Filters: [
+ { Name: `tag:${MANAGED_TAG_KEY}`, Values: ['true'] },
+ { Name: `tag:${REPO_TAG_KEY}`, Values: [repo] },
+ { Name: `tag:${POOL_TAG_KEY}`, Values: [poolTag] },
+ { Name: 'instance-state-name', Values: ['stopped'] },
+ { Name: 'instance-type', Values: [instanceType] },
+ ],
+ }));
+
+ for (const reservation of resp.Reservations || []) {
+ for (const instance of reservation.Instances || []) {
+ const tags = {};
+ for (const tag of instance.Tags || []) {
+ tags[tag.Key] = tag.Value;
+ }
+ // Strict client-side re-validation of the full signature.
+ if (tags[MANAGED_TAG_KEY] !== 'true' || tags[REPO_TAG_KEY] !== repo || tags[POOL_TAG_KEY] !== poolTag) {
+ continue;
+ }
+ if (instance.InstanceType !== instanceType || (amiArch && instance.Architecture && instance.Architecture !== amiArch)) {
+ continue;
+ }
+ return { instanceId: instance.InstanceId, subnetId: instance.SubnetId };
+ }
+ }
+ return null;
+}
+
+// Warm-start a stopped pool instance: rewrite its user-data with the fresh
+// registration token/label (so the per-boot service re-registers), update
+// its label + started-at tags, then StartInstances.
+async function warmStartInstance(instanceId, { userData, label }) {
+ const client = ec2Client();
+ await client.send(new ModifyInstanceAttributeCommand({
+ InstanceId: instanceId,
+ UserData: { Value: Buffer.from(userData).toString('base64') },
+ }));
+ await client.send(new CreateTagsCommand({
+ Resources: [instanceId],
+ Tags: [
+ { Key: LABEL_TAG_KEY, Value: label },
+ { Key: STARTED_AT_TAG_KEY, Value: new Date().toISOString() },
+ ],
+ }));
+ await withRetry('start_instance', () => client.send(new StartInstancesCommand({ InstanceIds: [instanceId] })));
+ log.info('warm_start', { instance_id: instanceId, label });
+ core.info(`Reused warm-pool EC2 instance ${instanceId}`);
+}
+
+// Stop (not terminate) an instance so it can be reused from the pool.
+async function stopInstanceById(instanceId) {
+ const client = ec2Client();
+ await withRetry('stop_instance', () => client.send(new StopInstancesCommand({ InstanceIds: [instanceId] })));
+ log.info('stop_instance', { instance_id: instanceId });
+ core.info(`AWS EC2 instance ${instanceId} is stopped (warm pool)`);
+}
+
+// Read the reuse-cycle counter tag for an instance (0 when absent).
+async function getInstanceCycles(instanceId) {
+ const client = ec2Client();
+ try {
+ const resp = await client.send(new DescribeTagsCommand({
+ Filters: [
+ { Name: 'resource-id', Values: [instanceId] },
+ { Name: 'key', Values: [CYCLES_TAG_KEY] },
+ ],
+ }));
+ const tag = (resp.Tags || []).find((t) => t.Key === CYCLES_TAG_KEY);
+ const n = tag ? parseInt(tag.Value, 10) : 0;
+ return Number.isFinite(n) ? n : 0;
+ } catch (error) {
+ log.debug('instance_cycles', { instance_id: instanceId, error: error.name, message: error.message });
+ return 0;
+ }
+}
+
+// Set the reuse-cycle counter tag.
+async function setInstanceCycles(instanceId, cycles) {
+ const client = ec2Client();
+ await client.send(new CreateTagsCommand({
+ Resources: [instanceId],
+ Tags: [{ Key: CYCLES_TAG_KEY, Value: String(cycles) }],
+ }));
+}
+
module.exports = {
startEc2Instance,
terminateInstanceById,
@@ -888,6 +1147,12 @@ module.exports = {
getConsoleOutputTail,
handleStartFailure,
listManagedInstances,
+ findStoppedPoolInstance,
+ warmStartInstance,
+ stopInstanceById,
+ getInstanceCycles,
+ setInstanceCycles,
+ buildReuseUserData,
// Exported for unit testing.
classifyRunError,
matchAmiArchitecture,
@@ -906,4 +1171,6 @@ module.exports = {
REPO_TAG_KEY,
LABEL_TAG_KEY,
STARTED_AT_TAG_KEY,
+ POOL_TAG_KEY,
+ CYCLES_TAG_KEY,
};
diff --git a/src/cleanup.js b/src/cleanup.js
index 66c69a9e..ad9cbc92 100644
--- a/src/cleanup.js
+++ b/src/cleanup.js
@@ -10,15 +10,26 @@ const REAP_GRACE_MINUTES = 15;
// GitHub runner (or null if none is registered). Pure function — no I/O —
// so the full decision matrix is unit-testable.
//
+// stopped, older than stopped-max-age -> reap (drain idle warm pool)
+// stopped, within stopped-max-age -> skip
// within grace period -> skip (protect in-flight starts)
// no runner registered -> reap (leaked instance)
// runner busy -> skip (job in progress, any age)
// runner idle, older than max -> reap + deregister
// runner idle, within max age -> skip
function decideReap(instance, runner, opts) {
- const { nowMs, maxAgeMinutes, graceMinutes } = opts;
+ const { nowMs, maxAgeMinutes, graceMinutes, stoppedMaxAgeMinutes } = opts;
const ageMinutes = instance.startedAtMs != null ? (nowMs - instance.startedAtMs) / 60000 : Infinity;
+ // Stopped warm-pool instances: drain those older than the stopped max-age
+ // so pools don't accrete EBS cost forever. No runner check — it's stopped.
+ if (instance.state === 'stopped') {
+ if (stoppedMaxAgeMinutes != null && ageMinutes > stoppedMaxAgeMinutes) {
+ return { action: 'reap', reason: 'stopped-past-max-age', deregister: false };
+ }
+ return { action: 'skip', reason: 'stopped-within-max-age', deregister: false };
+ }
+
if (ageMinutes < graceMinutes) {
return { action: 'skip', reason: 'within-grace-period', deregister: false };
}
@@ -49,6 +60,7 @@ async function runReaper(deps, opts = {}) {
const { listManagedInstances, getRunnerByLabel, terminateInstance, deregisterRunner, now } = deps;
const maxAgeMinutes = opts.maxAgeMinutes;
const graceMinutes = opts.graceMinutes ?? REAP_GRACE_MINUTES;
+ const stoppedMaxAgeMinutes = opts.stoppedMaxAgeMinutes;
const dryRun = !!opts.dryRun;
const instances = await listManagedInstances();
@@ -58,8 +70,9 @@ async function runReaper(deps, opts = {}) {
let skipped = 0;
for (const instance of instances) {
- const runner = instance.label ? await getRunnerByLabel(instance.label) : null;
- const decision = decideReap(instance, runner, { nowMs, maxAgeMinutes, graceMinutes });
+ // Stopped instances have no live runner to cross-check.
+ const runner = (instance.state !== 'stopped' && instance.label) ? await getRunnerByLabel(instance.label) : null;
+ const decision = decideReap(instance, runner, { nowMs, maxAgeMinutes, graceMinutes, stoppedMaxAgeMinutes });
const row = {
instanceId: instance.instanceId,
label: instance.label,
diff --git a/src/config.js b/src/config.js
index b22531ce..685157b0 100644
--- a/src/config.js
+++ b/src/config.js
@@ -24,6 +24,10 @@ class Config {
allowPartial: core.getInput('allow-partial') || 'false',
preRunnerScript: core.getInput('pre-runner-script'),
userDataTemplate: core.getInput('user-data-template'),
+ reuse: core.getInput('reuse') || 'terminate',
+ reusePoolTag: core.getInput('reuse-pool-tag') || 'default',
+ reuseMaxCycles: core.getInput('reuse-max-cycles') || '20',
+ reaperStoppedMaxAge: core.getInput('reaper-stopped-max-age') || '1440',
iamRoleName: core.getInput('iam-role-name'),
runnerVersion: core.getInput('runner-version') || '2.335.1',
architecture: core.getInput('architecture') || 'x64',
@@ -78,12 +82,14 @@ class Config {
this.validateArchitectureInputs();
this.validateCountInput();
this.validateBootstrapInputs();
+ this.validateReuseInputs();
} 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.
if (!this.input.label || (!this.input.ec2InstanceId && !(this.input.ec2InstanceIds && this.input.ec2InstanceIds.length))) {
throw new Error(`Not all the required inputs are provided for the 'stop' mode`);
}
+ this.validateReuseInputs();
} else if (this.input.mode === 'cleanup') {
// The reaper needs only the github-token (validated above) and
// operates on the current repository; max-age-minutes and dry-run
@@ -164,6 +170,16 @@ class Config {
}
}
+ // Validate the warm-pool reuse inputs (start + stop).
+ validateReuseInputs() {
+ if (!['terminate', 'stop'].includes(this.input.reuse)) {
+ throw new Error(`'reuse' must be one of: terminate, stop`);
+ }
+ if (!(/^[0-9]+$/.test(this.input.reuseMaxCycles) && Number(this.input.reuseMaxCycles) >= 1)) {
+ throw new Error(`'reuse-max-cycles' must be a positive integer`);
+ }
+ }
+
// Validate the multi-runner batch size.
validateCountInput() {
if (!(/^[0-9]+$/.test(this.input.count) && Number(this.input.count) >= 1)) {
diff --git a/src/index.js b/src/index.js
index 09f54777..e8d65071 100644
--- a/src/index.js
+++ b/src/index.js
@@ -21,6 +21,7 @@ const config = require('./config');
const log = require('./log');
const { waitForRunnerReady } = require('./wait');
const { runReaper, renderCleanupSummary, REAP_GRACE_MINUTES } = require('./cleanup');
+const { parseCsv } = require('./utils');
const core = require('@actions/core');
// Write directly to the $GITHUB_OUTPUT file. The bundled @actions/core
@@ -48,13 +49,49 @@ function setOutput(label, placement) {
core.setOutput('market-type-used', marketType);
}
+// Warm-pool fast path: reuse a stopped pool instance (count 1 only). Returns
+// a placement, or null to fall back to a cold launch (empty pool or a failed
+// start — the cold launch then joins the pool).
+async function tryWarmStart(label, githubRegistrationToken) {
+ const repo = `${config.githubContext.owner}/${config.githubContext.repo}`;
+ const instanceType = parseCsv(config.input.ec2InstanceType)[0];
+ const pool = await aws.findStoppedPoolInstance({
+ repo,
+ poolTag: config.input.reusePoolTag,
+ instanceType,
+ architecture: config.input.architecture,
+ });
+ if (!pool) {
+ log.info('warm_start', { outcome: 'pool_empty', pool: config.input.reusePoolTag });
+ return null;
+ }
+ try {
+ const userData = aws.buildReuseUserData(label, githubRegistrationToken);
+ await aws.warmStartInstance(pool.instanceId, { userData, label });
+ return { instanceIds: [pool.instanceId], instanceType, subnetId: pool.subnetId, marketType: 'reused' };
+ } catch (error) {
+ log.warn('warm_start', { instance_id: pool.instanceId, error: error.name, message: error.message });
+ core.warning(`Warm start of ${pool.instanceId} failed (${error.message}); cold-launching instead`);
+ return null;
+ }
+}
+
async function start() {
core.startGroup('start-runner');
try {
log.debug('start_inputs', config.input); // sanitized inside log.js
const label = config.generateUniqueLabel();
const githubRegistrationToken = await gh.getRegistrationToken();
- const placement = await aws.startEc2Instance(label, githubRegistrationToken);
+
+ // Warm pool: reuse a stopped instance when reuse:stop and count 1;
+ // otherwise (or on empty pool / failed reuse) cold-launch.
+ let placement = null;
+ if (config.input.reuse === 'stop' && Number(config.input.count) === 1) {
+ placement = await tryWarmStart(label, githubRegistrationToken);
+ }
+ if (!placement) {
+ placement = await aws.startEc2Instance(label, githubRegistrationToken);
+ }
const instanceIds = placement.instanceIds;
setOutput(label, placement);
for (const id of instanceIds) {
@@ -94,14 +131,29 @@ async function stop() {
? config.input.ec2InstanceIds
: [config.input.ec2InstanceId];
+ const reuse = config.input.reuse === 'stop';
+ const maxCycles = Number(config.input.reuseMaxCycles);
for (const id of instanceIds) {
try {
- await aws.terminateInstanceById(id);
+ if (reuse) {
+ // Warm pool: stop for reuse until the instance has served
+ // reuse-max-cycles jobs, then recycle it (terminate).
+ const cycles = await aws.getInstanceCycles(id);
+ if (cycles + 1 >= maxCycles) {
+ log.info('reuse', { instance_id: id, action: 'terminate', reason: 'max_cycles_reached', cycles });
+ await aws.terminateInstanceById(id);
+ } else {
+ await aws.setInstanceCycles(id, cycles + 1);
+ await aws.stopInstanceById(id);
+ }
+ } else {
+ await aws.terminateInstanceById(id);
+ }
} catch (error) {
if (error.name && error.name.includes('NotFound')) {
log.info('terminate_instance', { instance_id: id, skipped: true, reason: 'already_gone' });
} else {
- failures.push({ step: `terminate_instance:${id}`, error: error.name, message: error.message });
+ failures.push({ step: `${reuse ? 'stop' : 'terminate'}_instance:${id}`, error: error.name, message: error.message });
}
}
}
@@ -148,7 +200,8 @@ async function cleanup() {
const summary = await runReaper(
{
- listManagedInstances: () => aws.listManagedInstances(repo),
+ // Include stopped instances so idle warm-pool instances are drained.
+ listManagedInstances: () => aws.listManagedInstances(repo, ['pending', 'running', 'stopped']),
getRunnerByLabel: (label) => gh.getRunner(label),
terminateInstance: (id) => aws.terminateInstanceById(id),
deregisterRunner: (runnerId) => gh.deregisterRunner(runnerId),
@@ -157,6 +210,7 @@ async function cleanup() {
{
maxAgeMinutes: Number(config.input.maxAgeMinutes),
graceMinutes: REAP_GRACE_MINUTES,
+ stoppedMaxAgeMinutes: Number(config.input.reaperStoppedMaxAge),
dryRun,
},
);
diff --git a/tests/cleanup.test.js b/tests/cleanup.test.js
index 5a2e1e34..8822741d 100644
--- a/tests/cleanup.test.js
+++ b/tests/cleanup.test.js
@@ -35,6 +35,16 @@ describe('decideReap — decision matrix', () => {
const d = decideReap({ startedAtMs: minutesAgo(60) }, { id: 1, busy: false }, opts);
expect(d).toMatchObject({ action: 'skip', reason: 'runner-idle-within-max-age' });
});
+
+ test('drains a stopped pool instance older than stopped-max-age', () => {
+ const d = decideReap({ startedAtMs: minutesAgo(2000), state: 'stopped' }, null, { ...opts, stoppedMaxAgeMinutes: 1440 });
+ expect(d).toMatchObject({ action: 'reap', reason: 'stopped-past-max-age' });
+ });
+
+ test('keeps a stopped pool instance within stopped-max-age', () => {
+ const d = decideReap({ startedAtMs: minutesAgo(60), state: 'stopped' }, null, { ...opts, stoppedMaxAgeMinutes: 1440 });
+ expect(d).toMatchObject({ action: 'skip', reason: 'stopped-within-max-age' });
+ });
});
function fixtures() {
@@ -87,6 +97,27 @@ describe('runReaper', () => {
expect(summary.rows.filter((r) => r.action === 'reap').every((r) => r.performed === false)).toBe(true);
});
+ test('drains stopped instances without a GitHub runner lookup', async () => {
+ const instances = [
+ { instanceId: 'i-old', label: 'x', startedAtMs: minutesAgo(2000), state: 'stopped' },
+ { instanceId: 'i-fresh', label: 'y', startedAtMs: minutesAgo(30), state: 'stopped' },
+ ];
+ const terminateInstance = jest.fn().mockResolvedValue();
+ const getRunnerByLabel = jest.fn();
+
+ const summary = await runReaper({
+ listManagedInstances: () => Promise.resolve(instances),
+ getRunnerByLabel,
+ terminateInstance,
+ deregisterRunner: jest.fn(),
+ now: () => NOW,
+ }, { maxAgeMinutes: 120, stoppedMaxAgeMinutes: 1440, dryRun: false });
+
+ expect(terminateInstance.mock.calls.map((c) => c[0])).toEqual(['i-old']);
+ expect(getRunnerByLabel).not.toHaveBeenCalled(); // stopped instances skip the runner check
+ expect(summary).toMatchObject({ examined: 2, reaped: 1, skipped: 1 });
+ });
+
test('a termination failure is recorded and does not abort the sweep', async () => {
const instances = [
{ instanceId: 'i-x', label: 'x', startedAtMs: minutesAgo(200) },
diff --git a/tests/config.test.js b/tests/config.test.js
index 11d50bcb..1dab11e0 100644
--- a/tests/config.test.js
+++ b/tests/config.test.js
@@ -257,6 +257,34 @@ describe('Config — bootstrap extension inputs', () => {
});
});
+describe('Config — reuse / warm-pool inputs', () => {
+ test('defaults to terminate with default pool + cycles', () => {
+ const config = loadConfig(startModeInputs);
+ expect(config.input.reuse).toBe('terminate');
+ expect(config.input.reusePoolTag).toBe('default');
+ expect(config.input.reuseMaxCycles).toBe('20');
+ });
+
+ test('accepts reuse:stop with a pool tag and cycle cap', () => {
+ const config = loadConfig({ ...startModeInputs, 'reuse': 'stop', 'reuse-pool-tag': 'ci-medium', 'reuse-max-cycles': '10' });
+ expect(config.input.reuse).toBe('stop');
+ expect(config.input.reusePoolTag).toBe('ci-medium');
+ expect(config.input.reuseMaxCycles).toBe('10');
+ });
+
+ test('rejects an invalid reuse value', () => {
+ expectValidationFailure({ ...startModeInputs, 'reuse': 'recycle' }, /'reuse' must be one of/);
+ });
+
+ test('rejects a non-integer reuse-max-cycles', () => {
+ expectValidationFailure({ ...startModeInputs, 'reuse-max-cycles': 'lots' }, /'reuse-max-cycles' must be a positive integer/);
+ });
+
+ test('validates reuse in stop mode too', () => {
+ expectValidationFailure({ ...stopModeInputs, 'reuse': 'nope' }, /'reuse' must be one of/);
+ });
+});
+
describe('Config — architecture input', () => {
test('defaults to x64', () => {
expect(loadConfig(startModeInputs).input.architecture).toBe('x64');
diff --git a/tests/tags.test.js b/tests/tags.test.js
index 579f79f4..f13e7ce0 100644
--- a/tests/tags.test.js
+++ b/tests/tags.test.js
@@ -35,6 +35,8 @@ const tagsOf = (spec) => {
beforeEach(() => {
mockSend.mockReset();
config.input.awsResourceTags = [];
+ config.input.reuse = 'terminate';
+ config.input.reusePoolTag = 'default';
});
describe('buildTagSpecifications', () => {
@@ -57,6 +59,22 @@ describe('buildTagSpecifications', () => {
expect(t[aws.MANAGED_TAG_KEY]).toBe('true');
});
+ test('adds pool + cycles=0 tags when reuse is stop', () => {
+ config.input.reuse = 'stop';
+ config.input.reusePoolTag = 'ci-medium';
+ const t = tagsOf(aws.buildTagSpecifications('l', 'ts')[0]);
+ expect(t[aws.POOL_TAG_KEY]).toBe('ci-medium');
+ expect(t[aws.CYCLES_TAG_KEY]).toBe('0');
+ config.input.reuse = 'terminate';
+ config.input.reusePoolTag = undefined;
+ });
+
+ test('omits pool tags when reuse is terminate (default)', () => {
+ const t = tagsOf(aws.buildTagSpecifications('l', 'ts')[0]);
+ expect(t[aws.POOL_TAG_KEY]).toBeUndefined();
+ expect(t[aws.CYCLES_TAG_KEY]).toBeUndefined();
+ });
+
test('never lets a user tag override a reserved signature key', () => {
config.input.awsResourceTags = [{ Key: aws.MANAGED_TAG_KEY, Value: 'false' }, { Key: aws.REPO_TAG_KEY, Value: 'evil/repo' }];
const t = tagsOf(aws.buildTagSpecifications('l', 'ts')[0]);
@@ -87,6 +105,7 @@ describe('listManagedInstances', () => {
Instances: [{
InstanceId: 'i-1',
LaunchTime: '2026-07-02T10:00:00.000Z',
+ State: { Name: 'running' },
Tags: [
{ Key: aws.MANAGED_TAG_KEY, Value: 'true' },
{ Key: aws.REPO_TAG_KEY, Value: 'my-org/my-repo' },
@@ -97,7 +116,7 @@ describe('listManagedInstances', () => {
}],
});
const result = await aws.listManagedInstances('my-org/my-repo');
- expect(result).toEqual([{ instanceId: 'i-1', label: 'runner-xyz', startedAtMs: Date.parse('2026-07-02T12:00:00.000Z') }]);
+ expect(result).toEqual([{ instanceId: 'i-1', label: 'runner-xyz', startedAtMs: Date.parse('2026-07-02T12:00:00.000Z'), state: 'running' }]);
});
test('falls back to LaunchTime when started-at tag is missing', async () => {
diff --git a/tests/warmpool.test.js b/tests/warmpool.test.js
new file mode 100644
index 00000000..e260ff22
--- /dev/null
+++ b/tests/warmpool.test.js
@@ -0,0 +1,147 @@
+// Tests for warm-pool (reuse: stop) support: findStoppedPoolInstance strict
+// matching, warmStartInstance ordering, stop/cycle helpers, and the per-boot
+// re-registration hook in the generated user-data.
+const mockSend = jest.fn();
+jest.mock('@aws-sdk/client-ec2', () => ({
+ EC2Client: jest.fn(() => ({ send: mockSend })),
+ DescribeImagesCommand: jest.fn((p) => ({ __command: 'DescribeImages', ...p })),
+ DescribeInstancesCommand: jest.fn((p) => ({ __command: 'DescribeInstances', ...p })),
+ DescribeTagsCommand: jest.fn((p) => ({ __command: 'DescribeTags', ...p })),
+ GetConsoleOutputCommand: jest.fn((p) => ({ __command: 'GetConsoleOutput', ...p })),
+ RunInstancesCommand: jest.fn((p) => ({ __command: 'RunInstances', ...p })),
+ TerminateInstancesCommand: jest.fn((p) => ({ __command: 'TerminateInstances', ...p })),
+ StartInstancesCommand: jest.fn((p) => ({ __command: 'StartInstances', ...p })),
+ StopInstancesCommand: jest.fn((p) => ({ __command: 'StopInstances', ...p })),
+ CreateTagsCommand: jest.fn((p) => ({ __command: 'CreateTags', ...p })),
+ ModifyInstanceAttributeCommand: jest.fn((p) => ({ __command: 'ModifyInstanceAttribute', ...p })),
+ AssociateAddressCommand: jest.fn((p) => ({ __command: 'AssociateAddress', ...p })),
+ waitUntilInstanceRunning: jest.fn(),
+}));
+jest.mock('../src/retry', () => ({ withRetry: (_step, fn) => fn() }));
+jest.mock('../src/config', () => ({
+ input: { mode: 'start', debug: 'false', reuse: 'stop', reusePoolTag: 'ci', architecture: 'x64', awsResourceTags: [] },
+ githubContext: { owner: 'my-org', repo: 'my-repo' },
+}));
+jest.mock('@actions/core', () => ({
+ info: jest.fn(), warning: jest.fn(), error: jest.fn(), setFailed: jest.fn(),
+ getInput: jest.fn(), startGroup: jest.fn(), endGroup: jest.fn(), debug: jest.fn(),
+}));
+
+const aws = require('../src/aws');
+const commandsSent = () => mockSend.mock.calls.map((c) => c[0].__command);
+
+beforeEach(() => mockSend.mockReset());
+
+const poolInstance = (over = {}) => ({
+ InstanceId: 'i-good',
+ InstanceType: 'c7i.4xlarge',
+ Architecture: 'x86_64',
+ SubnetId: 'subnet-1',
+ Tags: [
+ { Key: aws.MANAGED_TAG_KEY, Value: 'true' },
+ { Key: aws.REPO_TAG_KEY, Value: 'my-org/my-repo' },
+ { Key: aws.POOL_TAG_KEY, Value: 'ci' },
+ ],
+ ...over,
+});
+
+describe('findStoppedPoolInstance', () => {
+ const query = { repo: 'my-org/my-repo', poolTag: 'ci', instanceType: 'c7i.4xlarge', architecture: 'x64' };
+
+ test('returns a matching stopped instance (id + subnet)', async () => {
+ mockSend.mockResolvedValueOnce({ Reservations: [{ Instances: [poolInstance()] }] });
+ await expect(aws.findStoppedPoolInstance(query)).resolves.toEqual({ instanceId: 'i-good', subnetId: 'subnet-1' });
+ const cmd = mockSend.mock.calls[0][0];
+ expect(cmd.__command).toBe('DescribeInstances');
+ expect(cmd.Filters).toEqual(expect.arrayContaining([
+ { Name: `tag:${aws.POOL_TAG_KEY}`, Values: ['ci'] },
+ { Name: 'instance-state-name', Values: ['stopped'] },
+ { Name: 'instance-type', Values: ['c7i.4xlarge'] },
+ ]));
+ });
+
+ test('SAFETY: skips near-miss instances (wrong pool / arch / type / unmanaged)', async () => {
+ mockSend.mockResolvedValueOnce({ Reservations: [{ Instances: [
+ poolInstance({ InstanceId: 'i-wrong-pool', Tags: [
+ { Key: aws.MANAGED_TAG_KEY, Value: 'true' }, { Key: aws.REPO_TAG_KEY, Value: 'my-org/my-repo' }, { Key: aws.POOL_TAG_KEY, Value: 'other' },
+ ] }),
+ poolInstance({ InstanceId: 'i-wrong-arch', Architecture: 'arm64' }),
+ poolInstance({ InstanceId: 'i-wrong-type', InstanceType: 'm5.large' }),
+ poolInstance({ InstanceId: 'i-unmanaged', Tags: [{ Key: aws.POOL_TAG_KEY, Value: 'ci' }] }),
+ ] }] });
+ await expect(aws.findStoppedPoolInstance(query)).resolves.toBeNull();
+ });
+
+ test('returns null when the pool is empty', async () => {
+ mockSend.mockResolvedValueOnce({ Reservations: [] });
+ await expect(aws.findStoppedPoolInstance(query)).resolves.toBeNull();
+ });
+});
+
+describe('warmStartInstance', () => {
+ test('rewrites user-data, updates tags, then starts — in that order', async () => {
+ mockSend.mockResolvedValue({});
+ await aws.warmStartInstance('i-1', { userData: '#!/bin/bash\ntrue', label: 'runner-new' });
+ expect(commandsSent()).toEqual(['ModifyInstanceAttribute', 'CreateTags', 'StartInstances']);
+ const modify = mockSend.mock.calls[0][0];
+ expect(modify.InstanceId).toBe('i-1');
+ expect(Buffer.from(modify.UserData.Value, 'base64').toString('utf8')).toContain('#!/bin/bash');
+ const tags = mockSend.mock.calls[1][0].Tags.map((t) => t.Key);
+ expect(tags).toContain(aws.LABEL_TAG_KEY);
+ });
+});
+
+describe('stopInstanceById', () => {
+ test('sends StopInstances', async () => {
+ mockSend.mockResolvedValue({});
+ await aws.stopInstanceById('i-9');
+ expect(commandsSent()).toEqual(['StopInstances']);
+ expect(mockSend.mock.calls[0][0].InstanceIds).toEqual(['i-9']);
+ });
+});
+
+describe('getInstanceCycles / setInstanceCycles', () => {
+ test('reads the cycles tag as an integer', async () => {
+ mockSend.mockResolvedValueOnce({ Tags: [{ Key: aws.CYCLES_TAG_KEY, Value: '7' }] });
+ await expect(aws.getInstanceCycles('i-1')).resolves.toBe(7);
+ });
+
+ test('returns 0 when the cycles tag is absent', async () => {
+ mockSend.mockResolvedValueOnce({ Tags: [] });
+ await expect(aws.getInstanceCycles('i-1')).resolves.toBe(0);
+ });
+
+ test('returns 0 when DescribeTags fails', async () => {
+ mockSend.mockRejectedValueOnce(new Error('denied'));
+ await expect(aws.getInstanceCycles('i-1')).resolves.toBe(0);
+ });
+
+ test('writes the cycles tag', async () => {
+ mockSend.mockResolvedValue({});
+ await aws.setInstanceCycles('i-1', 3);
+ expect(commandsSent()).toEqual(['CreateTags']);
+ expect(mockSend.mock.calls[0][0].Tags).toEqual([{ Key: aws.CYCLES_TAG_KEY, Value: '3' }]);
+ });
+});
+
+describe('buildUserData — reuse: stop variant', () => {
+ const args = { runnerVersion: '2.335.1', owner: 'o', repo: 'r', label: 'l', githubRegistrationToken: 'TOK', shaX64: 'x', shaArm64: 'a' };
+
+ test('installs a per-boot systemd service + IMDS-read re-registration hook', () => {
+ const ud = aws.buildUserData({ ...args, reuse: 'stop' });
+ expect(ud).toContain('/opt/gh-runner-register.sh');
+ expect(ud).toContain('/etc/systemd/system/gh-runner.service');
+ expect(ud).toContain('systemctl enable --now gh-runner.service');
+ // Re-registration reads the current job params from IMDS user-data.
+ expect(ud).toContain('latest/user-data');
+ expect(ud).toContain("GH_TOKEN='TOK'");
+ expect(ud).toContain('./config.sh');
+ expect(ud).toContain('./run.sh');
+ });
+
+ test('default (terminate) variant has no warm-pool machinery (regression)', () => {
+ const ud = aws.buildUserData({ ...args, reuse: 'terminate' });
+ expect(ud).not.toContain('gh-runner.service');
+ expect(ud).not.toContain('/opt/gh-runner-register.sh');
+ });
+});