diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 51cd6347..fdc1d39d 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -68,16 +68,20 @@ jobs: steps: - name: Checkout uses: actions/checkout@v4 - - name: Extract runner version from src/aws.js + - name: Extract default runner version from action.yml id: extract run: | - version=$(grep -oE 'actions/runner/releases/download/v[0-9]+\.[0-9]+\.[0-9]+' src/aws.js | head -1 | sed 's|.*/v||') + # action.yml declares: + # runner-version: + # ... + # default: '2.333.1' + version=$(awk '/^ runner-version:/{found=1} found && /^ default:/{gsub(/[^0-9.]/, "", $2); print $2; exit}' action.yml) if [ -z "$version" ]; then - echo "::error::Could not locate the pinned actions/runner version in src/aws.js" + echo "::error::Could not locate the default runner-version in action.yml" exit 1 fi echo "version=$version" >> "$GITHUB_OUTPUT" - echo "Pinned actions/runner: v$version" + echo "Default actions/runner: v$version" - name: HEAD check the Linux x64 release asset env: VERSION: ${{ steps.extract.outputs.version }} diff --git a/action.yml b/action.yml index cc862031..8c95651d 100644 --- a/action.yml +++ b/action.yml @@ -70,6 +70,15 @@ inputs: IAM Role Name to attach to the created EC2 instance. This requires additional permissions on the AWS role used to launch instances. required: false + runner-version: + description: >- + Version of the actions/runner binary to download and register. + Must match a released tag from https://github.com/actions/runner/releases + (without the 'v' prefix). Defaults to the version tested with this action release. + Bumping this lets consumers pick up a newer runner (e.g. when GitHub gates + JS actions on a newer node runtime) without waiting for an action release. + required: false + default: '2.333.1' aws-resource-tags: description: >- Tags to attach to the launched EC2 instance and volume. diff --git a/dist/index.js b/dist/index.js index 80f6fd5e..502550fc 100644 --- a/dist/index.js +++ b/dist/index.js @@ -87903,21 +87903,66 @@ async function resolveImageId(client) { async function startEc2Instance(label, githubRegistrationToken) { const client = ec2Client(); - // User data scripts are run as the root user. - // Docker and git are necessary for GitHub runner and should be pre-installed on the AMI. - const userData = [ + // User-data runs as root. We install dependencies + create a dedicated + // 'runner' user, then drop to that user for every subsequent step via + // a sudo-heredoc. The runner never needs root and never gets it; the + // old RUNNER_ALLOW_RUNASROOT=1 escape hatch is gone. + // + // Runner version is read from config so consumers can override without + // waiting for an action release (see #10 for the motivation chain). + // + // The tarball is SHA-256 verified against actions/runner's published + // checksum before extraction — same defense-in-depth pattern the + // provider repo uses for its Go / Terraform downloads. + // + // --ephemeral tells GitHub to auto-deregister the runner after it + // completes a single job; the stop-runner step's explicit removeRunner() + // call becomes belt-and-braces rather than the primary deregister path. + const runnerVersion = config.input.runnerVersion; + const owner = config.githubContext.owner; + const repo = config.githubContext.repo; + const userDataScript = [ '#!/bin/bash', + 'set -euo pipefail', + '', + '# Root-required setup.', 'mount -o remount,size=1G /tmp', - 'yum install -y libicu make', - 'mkdir actions-runner && cd actions-runner', - 'case $(uname -m) in aarch64) ARCH="arm64" ;; amd64|x86_64) ARCH="x64" ;; esac && export RUNNER_ARCH=${ARCH}', - 'curl -O -L https://github.com/actions/runner/releases/download/v2.333.1/actions-runner-linux-${RUNNER_ARCH}-2.333.1.tar.gz', - 'tar xzf ./actions-runner-linux-${RUNNER_ARCH}-2.333.1.tar.gz', - 'export RUNNER_ALLOW_RUNASROOT=1', + 'yum install -y libicu make sudo', + '', + '# Create the non-root runner user.', + 'if ! id runner >/dev/null 2>&1; then', + ' useradd -m -s /bin/bash runner', + 'fi', + '', + '# Drop to the runner user for download + configure + run.', + "sudo -u runner -H bash <<'RUNNER_BOOTSTRAP'", + 'set -euo pipefail', + 'cd "$HOME"', + 'mkdir -p actions-runner && cd actions-runner', + '', + 'case "$(uname -m)" in', + ' aarch64) RUNNER_ARCH="arm64" ;;', + ' 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"', + 'expected="$(curl -fsSL "$BASE/$TARBALL.sha256" | awk \'{print $1}\')"', + 'echo "$expected $TARBALL" | sha256sum -c -', + '', + 'tar xzf "$TARBALL"', + '', 'export DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=1', - `./config.sh --url https://github.com/${config.githubContext.owner}/${config.githubContext.repo} --token ${githubRegistrationToken} --labels ${label}`, + `./config.sh --url "https://github.com/${owner}/${repo}" --token "${githubRegistrationToken}" --labels "${label}" --ephemeral --unattended --disableupdate`, './run.sh', + 'RUNNER_BOOTSTRAP', + '', ]; + const userData = userDataScript; config.input.ec2ImageId = await resolveImageId(client); @@ -88003,6 +88048,7 @@ class Config { label: core.getInput('label'), ec2InstanceId: core.getInput('ec2-instance-id'), iamRoleName: core.getInput('iam-role-name'), + runnerVersion: core.getInput('runner-version') || '2.333.1', }; const tags = JSON.parse(core.getInput('aws-resource-tags')); diff --git a/src/aws.js b/src/aws.js index 0334d312..44219838 100644 --- a/src/aws.js +++ b/src/aws.js @@ -57,21 +57,66 @@ async function resolveImageId(client) { async function startEc2Instance(label, githubRegistrationToken) { const client = ec2Client(); - // User data scripts are run as the root user. - // Docker and git are necessary for GitHub runner and should be pre-installed on the AMI. - const userData = [ + // User-data runs as root. We install dependencies + create a dedicated + // 'runner' user, then drop to that user for every subsequent step via + // a sudo-heredoc. The runner never needs root and never gets it; the + // old RUNNER_ALLOW_RUNASROOT=1 escape hatch is gone. + // + // Runner version is read from config so consumers can override without + // waiting for an action release (see #10 for the motivation chain). + // + // The tarball is SHA-256 verified against actions/runner's published + // checksum before extraction — same defense-in-depth pattern the + // provider repo uses for its Go / Terraform downloads. + // + // --ephemeral tells GitHub to auto-deregister the runner after it + // completes a single job; the stop-runner step's explicit removeRunner() + // call becomes belt-and-braces rather than the primary deregister path. + const runnerVersion = config.input.runnerVersion; + const owner = config.githubContext.owner; + const repo = config.githubContext.repo; + const userDataScript = [ '#!/bin/bash', + 'set -euo pipefail', + '', + '# Root-required setup.', 'mount -o remount,size=1G /tmp', - 'yum install -y libicu make', - 'mkdir actions-runner && cd actions-runner', - 'case $(uname -m) in aarch64) ARCH="arm64" ;; amd64|x86_64) ARCH="x64" ;; esac && export RUNNER_ARCH=${ARCH}', - 'curl -O -L https://github.com/actions/runner/releases/download/v2.333.1/actions-runner-linux-${RUNNER_ARCH}-2.333.1.tar.gz', - 'tar xzf ./actions-runner-linux-${RUNNER_ARCH}-2.333.1.tar.gz', - 'export RUNNER_ALLOW_RUNASROOT=1', + 'yum install -y libicu make sudo', + '', + '# Create the non-root runner user.', + 'if ! id runner >/dev/null 2>&1; then', + ' useradd -m -s /bin/bash runner', + 'fi', + '', + '# Drop to the runner user for download + configure + run.', + "sudo -u runner -H bash <<'RUNNER_BOOTSTRAP'", + 'set -euo pipefail', + 'cd "$HOME"', + 'mkdir -p actions-runner && cd actions-runner', + '', + 'case "$(uname -m)" in', + ' aarch64) RUNNER_ARCH="arm64" ;;', + ' 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"', + 'expected="$(curl -fsSL "$BASE/$TARBALL.sha256" | awk \'{print $1}\')"', + 'echo "$expected $TARBALL" | sha256sum -c -', + '', + 'tar xzf "$TARBALL"', + '', 'export DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=1', - `./config.sh --url https://github.com/${config.githubContext.owner}/${config.githubContext.repo} --token ${githubRegistrationToken} --labels ${label}`, + `./config.sh --url "https://github.com/${owner}/${repo}" --token "${githubRegistrationToken}" --labels "${label}" --ephemeral --unattended --disableupdate`, './run.sh', + 'RUNNER_BOOTSTRAP', + '', ]; + const userData = userDataScript; config.input.ec2ImageId = await resolveImageId(client); diff --git a/src/config.js b/src/config.js index bfd2696f..7e804b93 100644 --- a/src/config.js +++ b/src/config.js @@ -16,6 +16,7 @@ class Config { label: core.getInput('label'), ec2InstanceId: core.getInput('ec2-instance-id'), iamRoleName: core.getInput('iam-role-name'), + runnerVersion: core.getInput('runner-version') || '2.333.1', }; const tags = JSON.parse(core.getInput('aws-resource-tags')); diff --git a/tests/config.test.js b/tests/config.test.js index 6eb10415..60753d41 100644 --- a/tests/config.test.js +++ b/tests/config.test.js @@ -131,6 +131,18 @@ describe('Config — mode validation', () => { }); }); +describe('Config — runner-version input', () => { + test('defaults to 2.333.1 when input is unset', () => { + const config = loadConfig(startModeInputs); + expect(config.input.runnerVersion).toBe('2.333.1'); + }); + + test('honors an explicit runner-version override', () => { + const config = loadConfig({ ...startModeInputs, 'runner-version': '2.340.0' }); + expect(config.input.runnerVersion).toBe('2.340.0'); + }); +}); + describe('Config — generateUniqueLabel', () => { test('returns a 5-character alphanumeric string', () => { const config = loadConfig(startModeInputs);