Skip to content

Commit 2fd9d0f

Browse files
committed
feat: non-root runner user, --ephemeral flag, configurable runner version (Phase 4)
Closes #10. Biggest compatibility risk in the modernization plan, called out in the #15 tracker as needing a provider-repo dogfood before landing. ## Bootstrap rewrite The EC2 user-data now: - set -euo pipefail throughout — a silent useradd / tar / sha256sum failure kills the bootstrap instead of proceeding to a broken ./run.sh. - Creates a dedicated 'runner' user (idempotent — skipped if it already exists, so re-runs from a crash-loop don't explode). - Drops to that user via 'sudo -u runner -H bash <<RUNNER_BOOTSTRAP' for every subsequent step. The old 'export RUNNER_ALLOW_RUNASROOT=1' escape hatch is gone. - Fetches the runner tarball and SHA-256-verifies it against actions/runner's published '.sha256' sidecar before extraction. Same defense-in-depth pattern the provider repo uses for Go and Terraform downloads (namecheap/terraform-provider-namecheap#160). - Passes '--ephemeral --unattended --disableupdate' to config.sh. GitHub auto-deregisters the runner after one job — the existing removeRunner() API call in src/gh.js becomes belt-and-braces rather than the primary deregister path. --disableupdate keeps the runner binary stable for the short-lived ephemeral session. ## New 'runner-version' input Optional, defaults to '2.333.1' (the version this PR is tested against). Consumers can override without waiting for a new action release — useful when GitHub gates a JS action on a newer node runtime and we need to move fast. src/config.js reads it with a default fallback so old callers that don't set it continue to work. ## CI adjustment The existing verify-runner-url job greps the literal version string out of the source to HEAD-check the release asset. With the version now parameterized, the literal lives in action.yml's 'default:', so the extractor is rewritten to read it from there. ## Tests tests/config.test.js adds two cases: - defaults to 2.333.1 when runner-version input is unset - honors an explicit override Full suite: 23 tests pass across utils + config. ## Consumer impact (terraform-provider-namecheap acctest) - make testacc is 'go test' — no root required. - All setup steps (curl Go / Terraform, extract tarballs, write go-env.sh) write to $GITHUB_WORKSPACE which is writable by any runner user, not just root. - actions/checkout@v6 writes to the workspace, no root. - The workspace directory structure is unchanged beyond its absolute path (/home/runner/actions-runner/_work/... instead of /actions-runner/_work/...). GITHUB_WORKSPACE, HOME, and relative paths all resolve the same way. The dogfood SHA-pin rotation will be opened on the provider repo after this merges, mirroring the pattern from machulav#158machulav#159. Signed-off-by: yuriyryabikov <22548029+kurok@users.noreply.github.com>
1 parent a1bd2f9 commit 2fd9d0f

6 files changed

Lines changed: 141 additions & 24 deletions

File tree

.github/workflows/pr.yml

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -68,16 +68,20 @@ jobs:
6868
steps:
6969
- name: Checkout
7070
uses: actions/checkout@v4
71-
- name: Extract runner version from src/aws.js
71+
- name: Extract default runner version from action.yml
7272
id: extract
7373
run: |
74-
version=$(grep -oE 'actions/runner/releases/download/v[0-9]+\.[0-9]+\.[0-9]+' src/aws.js | head -1 | sed 's|.*/v||')
74+
# action.yml declares:
75+
# runner-version:
76+
# ...
77+
# default: '2.333.1'
78+
version=$(awk '/^ runner-version:/{found=1} found && /^ default:/{gsub(/[^0-9.]/, "", $2); print $2; exit}' action.yml)
7579
if [ -z "$version" ]; then
76-
echo "::error::Could not locate the pinned actions/runner version in src/aws.js"
80+
echo "::error::Could not locate the default runner-version in action.yml"
7781
exit 1
7882
fi
7983
echo "version=$version" >> "$GITHUB_OUTPUT"
80-
echo "Pinned actions/runner: v$version"
84+
echo "Default actions/runner: v$version"
8185
- name: HEAD check the Linux x64 release asset
8286
env:
8387
VERSION: ${{ steps.extract.outputs.version }}

action.yml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,15 @@ inputs:
7070
IAM Role Name to attach to the created EC2 instance.
7171
This requires additional permissions on the AWS role used to launch instances.
7272
required: false
73+
runner-version:
74+
description: >-
75+
Version of the actions/runner binary to download and register.
76+
Must match a released tag from https://github.com/actions/runner/releases
77+
(without the 'v' prefix). Defaults to the version tested with this action release.
78+
Bumping this lets consumers pick up a newer runner (e.g. when GitHub gates
79+
JS actions on a newer node runtime) without waiting for an action release.
80+
required: false
81+
default: '2.333.1'
7382
aws-resource-tags:
7483
description: >-
7584
Tags to attach to the launched EC2 instance and volume.

dist/index.js

Lines changed: 56 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -87903,21 +87903,66 @@ async function resolveImageId(client) {
8790387903
async function startEc2Instance(label, githubRegistrationToken) {
8790487904
const client = ec2Client();
8790587905

87906-
// User data scripts are run as the root user.
87907-
// Docker and git are necessary for GitHub runner and should be pre-installed on the AMI.
87908-
const userData = [
87906+
// User-data runs as root. We install dependencies + create a dedicated
87907+
// 'runner' user, then drop to that user for every subsequent step via
87908+
// a sudo-heredoc. The runner never needs root and never gets it; the
87909+
// old RUNNER_ALLOW_RUNASROOT=1 escape hatch is gone.
87910+
//
87911+
// Runner version is read from config so consumers can override without
87912+
// waiting for an action release (see #10 for the motivation chain).
87913+
//
87914+
// The tarball is SHA-256 verified against actions/runner's published
87915+
// checksum before extraction — same defense-in-depth pattern the
87916+
// provider repo uses for its Go / Terraform downloads.
87917+
//
87918+
// --ephemeral tells GitHub to auto-deregister the runner after it
87919+
// completes a single job; the stop-runner step's explicit removeRunner()
87920+
// call becomes belt-and-braces rather than the primary deregister path.
87921+
const runnerVersion = config.input.runnerVersion;
87922+
const owner = config.githubContext.owner;
87923+
const repo = config.githubContext.repo;
87924+
const userDataScript = [
8790987925
'#!/bin/bash',
87926+
'set -euo pipefail',
87927+
'',
87928+
'# Root-required setup.',
8791087929
'mount -o remount,size=1G /tmp',
87911-
'yum install -y libicu make',
87912-
'mkdir actions-runner && cd actions-runner',
87913-
'case $(uname -m) in aarch64) ARCH="arm64" ;; amd64|x86_64) ARCH="x64" ;; esac && export RUNNER_ARCH=${ARCH}',
87914-
'curl -O -L https://github.com/actions/runner/releases/download/v2.333.1/actions-runner-linux-${RUNNER_ARCH}-2.333.1.tar.gz',
87915-
'tar xzf ./actions-runner-linux-${RUNNER_ARCH}-2.333.1.tar.gz',
87916-
'export RUNNER_ALLOW_RUNASROOT=1',
87930+
'yum install -y libicu make sudo',
87931+
'',
87932+
'# Create the non-root runner user.',
87933+
'if ! id runner >/dev/null 2>&1; then',
87934+
' useradd -m -s /bin/bash runner',
87935+
'fi',
87936+
'',
87937+
'# Drop to the runner user for download + configure + run.',
87938+
"sudo -u runner -H bash <<'RUNNER_BOOTSTRAP'",
87939+
'set -euo pipefail',
87940+
'cd "$HOME"',
87941+
'mkdir -p actions-runner && cd actions-runner',
87942+
'',
87943+
'case "$(uname -m)" in',
87944+
' aarch64) RUNNER_ARCH="arm64" ;;',
87945+
' amd64|x86_64) RUNNER_ARCH="x64" ;;',
87946+
' *) echo "unsupported arch: $(uname -m)" >&2; exit 1 ;;',
87947+
'esac',
87948+
'',
87949+
`RUNNER_VERSION="${runnerVersion}"`,
87950+
'TARBALL="actions-runner-linux-${RUNNER_ARCH}-${RUNNER_VERSION}.tar.gz"',
87951+
'BASE="https://github.com/actions/runner/releases/download/v${RUNNER_VERSION}"',
87952+
'',
87953+
'curl -fsSLo "$TARBALL" "$BASE/$TARBALL"',
87954+
'expected="$(curl -fsSL "$BASE/$TARBALL.sha256" | awk \'{print $1}\')"',
87955+
'echo "$expected $TARBALL" | sha256sum -c -',
87956+
'',
87957+
'tar xzf "$TARBALL"',
87958+
'',
8791787959
'export DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=1',
87918-
`./config.sh --url https://github.com/${config.githubContext.owner}/${config.githubContext.repo} --token ${githubRegistrationToken} --labels ${label}`,
87960+
`./config.sh --url "https://github.com/${owner}/${repo}" --token "${githubRegistrationToken}" --labels "${label}" --ephemeral --unattended --disableupdate`,
8791987961
'./run.sh',
87962+
'RUNNER_BOOTSTRAP',
87963+
'',
8792087964
];
87965+
const userData = userDataScript;
8792187966

8792287967
config.input.ec2ImageId = await resolveImageId(client);
8792387968

@@ -88003,6 +88048,7 @@ class Config {
8800388048
label: core.getInput('label'),
8800488049
ec2InstanceId: core.getInput('ec2-instance-id'),
8800588050
iamRoleName: core.getInput('iam-role-name'),
88051+
runnerVersion: core.getInput('runner-version') || '2.333.1',
8800688052
};
8800788053

8800888054
const tags = JSON.parse(core.getInput('aws-resource-tags'));

src/aws.js

Lines changed: 55 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -57,21 +57,66 @@ async function resolveImageId(client) {
5757
async function startEc2Instance(label, githubRegistrationToken) {
5858
const client = ec2Client();
5959

60-
// User data scripts are run as the root user.
61-
// Docker and git are necessary for GitHub runner and should be pre-installed on the AMI.
62-
const userData = [
60+
// User-data runs as root. We install dependencies + create a dedicated
61+
// 'runner' user, then drop to that user for every subsequent step via
62+
// a sudo-heredoc. The runner never needs root and never gets it; the
63+
// old RUNNER_ALLOW_RUNASROOT=1 escape hatch is gone.
64+
//
65+
// Runner version is read from config so consumers can override without
66+
// waiting for an action release (see #10 for the motivation chain).
67+
//
68+
// The tarball is SHA-256 verified against actions/runner's published
69+
// checksum before extraction — same defense-in-depth pattern the
70+
// provider repo uses for its Go / Terraform downloads.
71+
//
72+
// --ephemeral tells GitHub to auto-deregister the runner after it
73+
// completes a single job; the stop-runner step's explicit removeRunner()
74+
// call becomes belt-and-braces rather than the primary deregister path.
75+
const runnerVersion = config.input.runnerVersion;
76+
const owner = config.githubContext.owner;
77+
const repo = config.githubContext.repo;
78+
const userDataScript = [
6379
'#!/bin/bash',
80+
'set -euo pipefail',
81+
'',
82+
'# Root-required setup.',
6483
'mount -o remount,size=1G /tmp',
65-
'yum install -y libicu make',
66-
'mkdir actions-runner && cd actions-runner',
67-
'case $(uname -m) in aarch64) ARCH="arm64" ;; amd64|x86_64) ARCH="x64" ;; esac && export RUNNER_ARCH=${ARCH}',
68-
'curl -O -L https://github.com/actions/runner/releases/download/v2.333.1/actions-runner-linux-${RUNNER_ARCH}-2.333.1.tar.gz',
69-
'tar xzf ./actions-runner-linux-${RUNNER_ARCH}-2.333.1.tar.gz',
70-
'export RUNNER_ALLOW_RUNASROOT=1',
84+
'yum install -y libicu make sudo',
85+
'',
86+
'# Create the non-root runner user.',
87+
'if ! id runner >/dev/null 2>&1; then',
88+
' useradd -m -s /bin/bash runner',
89+
'fi',
90+
'',
91+
'# Drop to the runner user for download + configure + run.',
92+
"sudo -u runner -H bash <<'RUNNER_BOOTSTRAP'",
93+
'set -euo pipefail',
94+
'cd "$HOME"',
95+
'mkdir -p actions-runner && cd actions-runner',
96+
'',
97+
'case "$(uname -m)" in',
98+
' aarch64) RUNNER_ARCH="arm64" ;;',
99+
' amd64|x86_64) RUNNER_ARCH="x64" ;;',
100+
' *) echo "unsupported arch: $(uname -m)" >&2; exit 1 ;;',
101+
'esac',
102+
'',
103+
`RUNNER_VERSION="${runnerVersion}"`,
104+
'TARBALL="actions-runner-linux-${RUNNER_ARCH}-${RUNNER_VERSION}.tar.gz"',
105+
'BASE="https://github.com/actions/runner/releases/download/v${RUNNER_VERSION}"',
106+
'',
107+
'curl -fsSLo "$TARBALL" "$BASE/$TARBALL"',
108+
'expected="$(curl -fsSL "$BASE/$TARBALL.sha256" | awk \'{print $1}\')"',
109+
'echo "$expected $TARBALL" | sha256sum -c -',
110+
'',
111+
'tar xzf "$TARBALL"',
112+
'',
71113
'export DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=1',
72-
`./config.sh --url https://github.com/${config.githubContext.owner}/${config.githubContext.repo} --token ${githubRegistrationToken} --labels ${label}`,
114+
`./config.sh --url "https://github.com/${owner}/${repo}" --token "${githubRegistrationToken}" --labels "${label}" --ephemeral --unattended --disableupdate`,
73115
'./run.sh',
116+
'RUNNER_BOOTSTRAP',
117+
'',
74118
];
119+
const userData = userDataScript;
75120

76121
config.input.ec2ImageId = await resolveImageId(client);
77122

src/config.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ class Config {
1616
label: core.getInput('label'),
1717
ec2InstanceId: core.getInput('ec2-instance-id'),
1818
iamRoleName: core.getInput('iam-role-name'),
19+
runnerVersion: core.getInput('runner-version') || '2.333.1',
1920
};
2021

2122
const tags = JSON.parse(core.getInput('aws-resource-tags'));

tests/config.test.js

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,18 @@ describe('Config — mode validation', () => {
131131
});
132132
});
133133

134+
describe('Config — runner-version input', () => {
135+
test('defaults to 2.333.1 when input is unset', () => {
136+
const config = loadConfig(startModeInputs);
137+
expect(config.input.runnerVersion).toBe('2.333.1');
138+
});
139+
140+
test('honors an explicit runner-version override', () => {
141+
const config = loadConfig({ ...startModeInputs, 'runner-version': '2.340.0' });
142+
expect(config.input.runnerVersion).toBe('2.340.0');
143+
});
144+
});
145+
134146
describe('Config — generateUniqueLabel', () => {
135147
test('returns a 5-character alphanumeric string', () => {
136148
const config = loadConfig(startModeInputs);

0 commit comments

Comments
 (0)