Skip to content
This repository was archived by the owner on Mar 25, 2026. It is now read-only.

Commit 1b06fcc

Browse files
authored
Feature/support for jit (machulav#261)
* first version of jit compability * modified readme to meet the new implementations
1 parent 155be2c commit 1b06fcc

File tree

12 files changed

+5661
-1053
lines changed

12 files changed

+5661
-1053
lines changed

.env.example

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,3 +22,5 @@ GITHUB_REPOSITORY=
2222
INPUT_EC2-VOLUME-SIZE=
2323
INPUT_EC2-DEVICE-NAME=
2424
INPUT_EC2-VOLUME-TYPE=
25+
INPUT_USE-JIT=
26+
INPUT_RUNNER-GROUP-ID=

CHANGELOG.md

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
# Changelog
2+
3+
## [Unreleased]
4+
5+
### Added
6+
- JIT (Just-In-Time) runner support via new `use-jit` input (default: `false`).
7+
JIT runners use GitHub's `generate-jitconfig` API, skip `config.sh`,
8+
and auto-deregister after completing one job.
9+
- New `runner-group-id` input for specifying the runner group when using JIT mode (default: `1`).
10+
- Validation: `use-jit` and `run-runner-as-service` cannot be used together (JIT is single-use).
11+
- New `runner-debug` input (default: `false`) for verbose debug logging. When enabled,
12+
injects detailed echo statements into the setup script and polls EC2 serial console
13+
output during runner registration. Requires `ec2:GetConsoleOutput` IAM permission.
14+
- New `availability-zones-config` input for multi-AZ failover. The action tries each
15+
configuration in sequence until an instance is successfully launched.
16+
- New `metadata-options` input for configuring EC2 instance metadata (e.g. IMDSv2).
17+
- New `packages` input for installing packages via cloud-init during boot.
18+
- New `region` output for tracking which AWS region the instance was launched in.
19+
- EC2 console output polling via `GetConsoleOutputCommand` for remote debugging.
20+
- Test suite expanded to 25 tests covering JIT, debug mode, cloud-boothook,
21+
runuser, tolerant chown, stale config cleanup, and package installation.
22+
23+
### Changed
24+
- Switched user-data format from `#cloud-config` with `runcmd` to `#cloud-boothook`.
25+
This fixes compatibility with Amazon Linux 2023 and other AMIs where
26+
`cloud_final_modules` may be empty or misconfigured.
27+
- Replaced `su <user> -c` with `runuser -u <user> --` to avoid password prompts
28+
in non-interactive cloud-init contexts.
29+
- Made `chown` tolerant of permission errors (`|| true`) to prevent `set -e`
30+
from killing the script when `_diag/` files are owned by root.
31+
- Setup script now removes stale runner config files (`.runner`, `.credentials`,
32+
`.credentials_rsaparams`) before `config.sh` to handle AMIs created from
33+
previously configured runner instances.
34+
- Setup script logs are written to `/tmp/runner-setup.log` instead of
35+
`/var/log/user-data.log` and `/dev/console` (which may not be accessible).
36+
- Updated README with full documentation for all new inputs, IAM requirements
37+
for debug mode, and advanced usage sections (JIT, Multi-AZ, Debug).

README.md

Lines changed: 154 additions & 28 deletions
Large diffs are not rendered by default.

action.yml

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,21 @@ inputs:
135135
Example: '["git", "docker.io", "nodejs"]'
136136
required: false
137137
default: '[]'
138+
use-jit:
139+
description: >-
140+
Enable JIT (Just-In-Time) runner configuration. Uses GitHub's
141+
generate-jitconfig API instead of the traditional registration-token approach.
142+
JIT runners are single-use and auto-deregister after completing one job.
143+
Incompatible with 'run-runner-as-service: true'.
144+
required: false
145+
default: 'false'
146+
runner-group-id:
147+
description: >-
148+
The ID of the runner group to register the JIT runner in.
149+
Defaults to 1, which is the "Default" runner group for repository-level runners.
150+
Only used when 'use-jit' is true.
151+
required: false
152+
default: '1'
138153
runner-debug:
139154
description: >-
140155
Enable verbose debug logging for the runner setup.

dist/index.js

Lines changed: 124 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -145105,10 +145105,62 @@ function buildRunCommands(githubRegistrationToken, label) {
145105145105
return userData.filter(Boolean);
145106145106
}
145107145107

145108+
// Build the commands to run on the instance for JIT mode.
145109+
// JIT runners skip config.sh entirely and pass the encoded config directly to run.sh.
145110+
function buildJitRunCommands(encodedJitConfig) {
145111+
const debug = config.input.runnerDebug;
145112+
const dbg = (cmd) => debug ? cmd : null;
145113+
145114+
// Common preamble: fail-fast and log capture
145115+
const preamble = [
145116+
'#!/bin/bash',
145117+
'LOGFILE=/tmp/runner-setup.log',
145118+
'exec > >(tee -a "$LOGFILE") 2>&1',
145119+
'set -e',
145120+
dbg('echo "[RUNNER] =========================================="'),
145121+
dbg('echo "[RUNNER] JIT Setup script started at $(date -u)"'),
145122+
dbg('echo "[RUNNER] =========================================="'),
145123+
].filter(Boolean);
145124+
145125+
let userData;
145126+
if (config.input.runnerHomeDir) {
145127+
userData = [
145128+
...preamble,
145129+
`cd "${config.input.runnerHomeDir}"`,
145130+
'source /tmp/pre-runner-script.sh',
145131+
'export RUNNER_ALLOW_RUNASROOT=1',
145132+
// Remove stale runner config from AMI so run.sh doesn't get confused
145133+
'rm -f .runner .credentials .credentials_rsaparams',
145134+
];
145135+
} else {
145136+
userData = [
145137+
...preamble,
145138+
'mkdir actions-runner && cd actions-runner',
145139+
'source /tmp/pre-runner-script.sh',
145140+
'case $(uname -m) in aarch64) ARCH="arm64" ;; amd64|x86_64) ARCH="x64" ;; esac && export RUNNER_ARCH=${ARCH}',
145141+
`RUNNER_VERSION=$(curl -s "https://api.github.com/repos/actions/runner/releases/latest" | grep -o '"tag_name"[[:space:]]*:[[:space:]]*"[^"]*"' | sed 's/.*"tag_name"[[:space:]]*:[[:space:]]*"\\([^"]*\\)".*/\\1/' | tr -d "v")`,
145142+
'curl -O -L https://github.com/actions/runner/releases/download/v${RUNNER_VERSION}/actions-runner-linux-${RUNNER_ARCH}-${RUNNER_VERSION}.tar.gz',
145143+
'tar xzf ./actions-runner-linux-${RUNNER_ARCH}-${RUNNER_VERSION}.tar.gz',
145144+
'export RUNNER_ALLOW_RUNASROOT=1',
145145+
];
145146+
}
145147+
145148+
if (config.input.runAsUser) {
145149+
userData.push(`chown -R ${config.input.runAsUser} . 2>&1 || true`);
145150+
userData.push(`runuser -u ${config.input.runAsUser} -- ./run.sh --jitconfig ${encodedJitConfig}`);
145151+
} else {
145152+
userData.push(`./run.sh --jitconfig ${encodedJitConfig}`);
145153+
}
145154+
145155+
return userData;
145156+
}
145157+
145108145158
// Build user data as a cloud-boothook (runs during cloud-init init stage,
145109145159
// bypassing cloud_final_modules which may be empty on some AMIs)
145110-
function buildUserDataScript(githubRegistrationToken, label) {
145111-
const runCommands = buildRunCommands(githubRegistrationToken, label);
145160+
function buildUserDataScript(githubRegistrationToken, label, encodedJitConfig) {
145161+
const runCommands = encodedJitConfig
145162+
? buildJitRunCommands(encodedJitConfig)
145163+
: buildRunCommands(githubRegistrationToken, label);
145112145164

145113145165
const lines = [];
145114145166

@@ -145173,12 +145225,12 @@ function buildMarketOptions() {
145173145225
};
145174145226
}
145175145227

145176-
async function createEc2InstanceWithParams(imageId, subnetId, securityGroupId, label, githubRegistrationToken, region) {
145228+
async function createEc2InstanceWithParams(imageId, subnetId, securityGroupId, label, githubRegistrationToken, region, encodedJitConfig) {
145177145229
// Region is always specified now, so we can directly use it
145178145230
const ec2ClientOptions = { region };
145179145231
const ec2 = new EC2Client(ec2ClientOptions);
145180145232

145181-
const userData = buildUserDataScript(githubRegistrationToken, label);
145233+
const userData = buildUserDataScript(githubRegistrationToken, label, encodedJitConfig);
145182145234

145183145235
const params = {
145184145236
ImageId: imageId,
@@ -145215,7 +145267,7 @@ async function createEc2InstanceWithParams(imageId, subnetId, securityGroupId, l
145215145267
return ec2InstanceId;
145216145268
}
145217145269

145218-
async function startEc2Instance(label, githubRegistrationToken) {
145270+
async function startEc2Instance(label, githubRegistrationToken, encodedJitConfig) {
145219145271
core.info(`Attempting to start EC2 instance using ${config.availabilityZones.length} availability zone configuration(s)`);
145220145272

145221145273
const errors = [];
@@ -145235,7 +145287,8 @@ async function startEc2Instance(label, githubRegistrationToken) {
145235145287
azConfig.securityGroupId,
145236145288
label,
145237145289
githubRegistrationToken,
145238-
region
145290+
region,
145291+
encodedJitConfig
145239145292
);
145240145293

145241145294
core.info(`Successfully started AWS EC2 instance ${ec2InstanceId} using availability zone configuration ${i + 1} in region ${region}`);
@@ -145341,6 +145394,8 @@ module.exports = {
145341145394
terminateEc2Instance,
145342145395
waitForInstanceRunning,
145343145396
getInstanceConsoleOutput,
145397+
// Exposed for testing only
145398+
_buildUserDataScriptForTest: buildUserDataScript,
145344145399
};
145345145400

145346145401

@@ -145379,12 +145434,14 @@ class Config {
145379145434
availabilityZonesConfig: core.getInput('availability-zones-config'),
145380145435
metadataOptions: JSON.parse(core.getInput('metadata-options') || '{}'),
145381145436
packages: JSON.parse(core.getInput('packages') || '[]'),
145437+
useJit: core.getInput('use-jit') === 'true',
145438+
runnerGroupId: parseInt(core.getInput('runner-group-id') || '1', 10),
145382145439
runnerDebug: core.getInput('runner-debug') === 'true',
145383145440
};
145384145441

145385145442
// Get the AWS_REGION environment variable
145386145443
this.defaultRegion = process.env.AWS_REGION;
145387-
145444+
145388145445
const tags = JSON.parse(core.getInput('aws-resource-tags'));
145389145446
this.tagSpecifications = null;
145390145447
if (tags.length > 0) {
@@ -145422,12 +145479,12 @@ class Config {
145422145479
if (this.input.availabilityZonesConfig) {
145423145480
try {
145424145481
this.availabilityZones = JSON.parse(this.input.availabilityZonesConfig);
145425-
145482+
145426145483
// Validate each availability zone configuration
145427145484
if (!Array.isArray(this.availabilityZones)) {
145428145485
throw new Error('availability-zones-config must be a JSON array');
145429145486
}
145430-
145487+
145431145488
this.availabilityZones.forEach((az, index) => {
145432145489
if (!az.imageId) {
145433145490
throw new Error(`Missing imageId in availability-zones-config at index ${index}`);
@@ -145460,7 +145517,7 @@ class Config {
145460145517
`Either provide 'availability-zones-config' or all of the following: 'ec2-image-id', 'subnet-id', 'security-group-id'`
145461145518
);
145462145519
}
145463-
145520+
145464145521
// Convert individual parameters to a single availability zone config
145465145522
this.availabilityZones.push({
145466145523
imageId: this.input.ec2ImageId,
@@ -145469,10 +145526,17 @@ class Config {
145469145526
// Add default region when using legacy configuration
145470145527
region: this.defaultRegion
145471145528
});
145472-
145529+
145473145530
core.info('Using individual parameters as a single availability zone configuration');
145474145531
}
145475145532

145533+
if (this.input.useJit && this.input.runAsService) {
145534+
throw new Error(
145535+
"The 'use-jit' and 'run-runner-as-service' inputs are incompatible. " +
145536+
'JIT runners are single-use and cannot run as a service.'
145537+
);
145538+
}
145539+
145476145540
if (this.marketType?.length > 0 && this.input.marketType !== 'spot') {
145477145541
throw new Error('Invalid `market-type` input. Allowed values: spot.');
145478145542
}
@@ -145500,6 +145564,8 @@ try {
145500145564
core.setFailed(error.message);
145501145565
}
145502145566

145567+
module.exports.Config = Config;
145568+
145503145569

145504145570
/***/ }),
145505145571

@@ -145539,6 +145605,33 @@ async function getRegistrationToken() {
145539145605
}
145540145606
}
145541145607

145608+
// generate a JIT (Just-In-Time) runner configuration via the GitHub API
145609+
async function getJitRunnerConfig(label) {
145610+
const octokit = github.getOctokit(config.input.githubToken);
145611+
145612+
try {
145613+
const response = await octokit.request(
145614+
'POST /repos/{owner}/{repo}/actions/runners/generate-jitconfig',
145615+
{
145616+
...config.githubContext,
145617+
name: `ec2-${label}`,
145618+
runner_group_id: config.input.runnerGroupId,
145619+
labels: [label],
145620+
work_folder: '_work',
145621+
}
145622+
);
145623+
145624+
core.info('GitHub JIT runner configuration is received');
145625+
return {
145626+
runnerId: response.data.runner.id,
145627+
encodedJitConfig: response.data.encoded_jit_config,
145628+
};
145629+
} catch (error) {
145630+
core.error('GitHub JIT runner configuration generation error');
145631+
throw error;
145632+
}
145633+
}
145634+
145542145635
async function removeRunner() {
145543145636
const runner = await getRunner(config.input.label);
145544145637
const octokit = github.getOctokit(config.input.githubToken);
@@ -145604,6 +145697,7 @@ async function waitForRunnerRegistered(label, onPollCallback) {
145604145697

145605145698
module.exports = {
145606145699
getRegistrationToken,
145700+
getJitRunnerConfig,
145607145701
removeRunner,
145608145702
waitForRunnerRegistered,
145609145703
};
@@ -147590,8 +147684,19 @@ function setOutput(label, ec2InstanceId, region) {
147590147684

147591147685
async function start() {
147592147686
const label = config.input.label ? config.input.label : config.generateUniqueLabel();
147593-
const githubRegistrationToken = await gh.getRegistrationToken();
147594-
const result = await aws.startEc2Instance(label, githubRegistrationToken);
147687+
147688+
let githubRegistrationToken = null;
147689+
let encodedJitConfig = null;
147690+
147691+
if (config.input.useJit) {
147692+
const jitConfig = await gh.getJitRunnerConfig(label);
147693+
encodedJitConfig = jitConfig.encodedJitConfig;
147694+
core.info(`JIT runner created with runner ID: ${jitConfig.runnerId}`);
147695+
} else {
147696+
githubRegistrationToken = await gh.getRegistrationToken();
147697+
}
147698+
147699+
const result = await aws.startEc2Instance(label, githubRegistrationToken, encodedJitConfig);
147595147700
const ec2InstanceId = result.ec2InstanceId;
147596147701
const region = result.region;
147597147702

@@ -147623,7 +147728,12 @@ async function start() {
147623147728

147624147729
async function stop() {
147625147730
await aws.terminateEc2Instance();
147626-
await gh.removeRunner();
147731+
147732+
if (config.input.useJit) {
147733+
core.info('JIT runner auto-deregisters after job completion. Skipping runner removal.');
147734+
} else {
147735+
await gh.removeRunner();
147736+
}
147627147737
}
147628147738

147629147739
(async function () {

0 commit comments

Comments
 (0)