Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -319,6 +319,7 @@ Now you're ready to go!
| `aws-resource-tags` | Optional. Used only with the `start` mode. | Specifies tags to add to the EC2 instance and any attached storage. <br><br> This field is a stringified JSON array of tag objects, each containing a `Key` and `Value` field (see example below). <br><br> Setting this requires additional AWS permissions for the role launching the instance (see above). |
| `eip-allocation-id` | Optional. Used only with the `start` mode. | Allocation Id of an Elastic IP to associate with the runner instance once it is running. |
| `runner-version` | Optional. Used only with the `start` mode. | Version of the `actions/runner` binary to download and register (default `2.335.1`). <br><br> Must have a matching entry in `src/runner-checksums.js`; the action verifies the downloaded tarball's SHA-256 against that table before extraction. |
| `architecture` | Optional. Used only with the `start` mode. | Runner CPU architecture: `x64` (default) or `arm64` (Graviton). Must match the AMI (validated at start). All types in an `ec2-instance-type` fallback list must share this arch. See [Running on Graviton (arm64)](#running-on-graviton-arm64). |
| `http-tokens` | Optional. Used only with the `start` mode. | Instance Metadata Service (IMDS) token mode (default `required`). <br><br> - `required` — IMDSv2 only; mitigates SSRF-style credential theft. <br> - `optional` — also allows IMDSv1; set only if a workload on the runner needs it. |
| `encrypt-ebs` | Optional. Used only with the `start` mode. | When `true`, the root EBS volume is created with SSE-EBS encryption using the account's default AWS-managed key (default `false`). Volume size / type / IOPS are preserved from the AMI unless overridden by the `volume-*` inputs below. |
| `volume-size` | Optional. Used only with the `start` mode. | Root EBS volume size in GiB. Omitted = AMI default (Amazon Linux 2023: 8 GiB). Must be ≥ the AMI snapshot size. See [Disk space for Docker workloads](#disk-space-for-docker-workloads). |
Expand Down Expand Up @@ -462,6 +463,24 @@ A single `subnet-id` + `ec2-instance-type` means a single point of failure: when

The `instance-type-used` and `subnet-id-used` outputs report what actually launched. Single values keep the original single-attempt behavior.

## Running on Graviton (arm64)

Graviton instances (c7g/m7g/r7g/…) deliver ~20–40% better price/performance for the compile/test workloads CI runs, and Go/Rust/Node/Java toolchains are all arm64-native. Set `architecture: arm64` and point at an arm64 AMI:

```yml
- name: Start EC2 runner
uses: namecheap/ec2-github-runner@v3
with:
mode: start
architecture: arm64
ec2-instance-type: c7g.2xlarge # (or a Graviton fallback list: c7g.2xlarge,c6g.2xlarge)
ec2-image-filters: '[{"Name": "name", "Values": ["al2023-ami-*-arm64"]}, {"Name": "architecture", "Values": ["arm64"]}]'
ec2-image-owner: amazon
# ... other inputs ...
```

The checksums for both architectures are pinned, so nothing else needs changing. The action **validates the AMI's architecture against this input at start** — a mismatch (e.g. an x64 AMI with `architecture: arm64`) fails in seconds with a clear message instead of a cryptic bootstrap timeout. When using a [capacity-fallback](#capacity-fallback-across-azs-and-instance-types) list, all instance types must share the architecture (mixed lists are rejected at config parse). Graviton pairs especially well with [spot](#saving-costs-with-spot) — Graviton spot is the deepest discount in EC2.

## Disk space for Docker workloads

The runner inherits the AMI's root volume size — 8 GiB on Amazon Linux 2023. Docker-based CI exhausts that almost immediately (a couple of large images plus build cache), and the job dies with `no space left on device` — one of the most common self-hosted-runner failures. Size the root volume for your workload:
Expand Down
10 changes: 10 additions & 0 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,16 @@ inputs:
override, add the corresponding hash to the table in a PR.
required: false
default: '2.335.1'
architecture:
description: >-
Used only with the 'start' mode. CPU architecture of the runner:
'x64' (default) or 'arm64' (Graviton). Must match the AMI's
architecture — a mismatch fails fast at start with a clear error. All
instance types in an 'ec2-instance-type' fallback list must share this
architecture. Checksums for both architectures are pinned, so no other
change is needed to run on Graviton.
required: false
default: 'x64'
encrypt-ebs:
description: >-
When 'true', the root EBS volume is created with SSE-EBS
Expand Down
66 changes: 66 additions & 0 deletions dist/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -104690,6 +104690,20 @@ const TRANSIENT_ERROR_CODES = new Set([
'Unavailable',
]);

// Map the action's `architecture` input to the AMI Architecture value that
// DescribeImages reports.
const AMI_ARCH_BY_INPUT = { x64: 'x86_64', arm64: 'arm64' };

// Compare an AMI's reported Architecture against the requested architecture.
// Returns true (match), false (mismatch — fail fast), or null (unknown —
// the AMI didn't report an architecture; caller warns and continues).
function matchAmiArchitecture(imageArchitecture, architecture) {
if (!imageArchitecture) {
return null;
}
return imageArchitecture === AMI_ARCH_BY_INPUT[architecture];
}

function classifyRunError(error) {
const name = error && error.name;
if (CAPACITY_ERROR_CODES.has(name)) {
Expand Down Expand Up @@ -105135,6 +105149,19 @@ async function startEc2Instance(label, githubRegistrationToken) {
const resolved = await resolveImage(client);
config.input.ec2ImageId = resolved.id;

// Fail fast on an AMI/architecture mismatch — the classic silent failure
// (an x64 tarball on an arm64 box, or vice versa) becomes a clear error
// in seconds instead of a registration timeout.
const amiArchMatch = matchAmiArchitecture(resolved.image.Architecture, config.input.architecture);
if (amiArchMatch === false) {
throw new Error(
`AMI ${resolved.id} is ${resolved.image.Architecture}, but 'architecture' is '${config.input.architecture}' ` +
`(expected ${AMI_ARCH_BY_INPUT[config.input.architecture]}). Point at an AMI matching the architecture, or fix the input.`,
);
} else if (amiArchMatch === null) {
log.warn('ami_architecture', { applied: false, reason: 'AMI did not report an architecture — skipping arch validation', ami_id: resolved.id });
}

// InstanceType and SubnetId are injected per attempt by the fallback
// chain (see below), so they are intentionally absent from the base.
const params = {
Expand Down Expand Up @@ -105427,6 +105454,7 @@ module.exports = {
listManagedInstances,
// Exported for unit testing.
classifyRunError,
matchAmiArchitecture,
launchWithFallback,
launchAcrossMarkets,
buildMarketOptions,
Expand Down Expand Up @@ -105595,6 +105623,7 @@ module.exports = {

const core = __nccwpck_require__(7484);
const github = __nccwpck_require__(3228);
const { parseCsv, instanceArch } = __nccwpck_require__(5804);

class Config {
constructor() {
Expand All @@ -105615,6 +105644,7 @@ class Config {
ec2InstanceId: core.getInput('ec2-instance-id'),
iamRoleName: core.getInput('iam-role-name'),
runnerVersion: core.getInput('runner-version') || '2.335.1',
architecture: core.getInput('architecture') || 'x64',
httpTokens: core.getInput('http-tokens') || 'required',
encryptEbs: core.getInput('encrypt-ebs') || 'false',
volumeSize: core.getInput('volume-size'),
Expand Down Expand Up @@ -105663,6 +105693,7 @@ class Config {
}
this.validateVolumeInputs();
this.validateMarketInputs();
this.validateArchitectureInputs();
} else if (this.input.mode === 'stop') {
if (!this.input.label || !this.input.ec2InstanceId) {
throw new Error(`Not all the required inputs are provided for the 'stop' mode`);
Expand Down Expand Up @@ -105721,6 +105752,24 @@ class Config {
}
}

// Validate the architecture input and that the ec2-instance-type fallback
// list is single-architecture and consistent with it. Placement and arch
// are kept orthogonal: a fallback chain must not mix arm64 and x64 types.
validateArchitectureInputs() {
const arch = this.input.architecture;
if (!['x64', 'arm64'].includes(arch)) {
throw new Error(`'architecture' must be one of: x64, arm64`);
}
const types = parseCsv(this.input.ec2InstanceType);
const arches = [...new Set(types.map(instanceArch))];
if (arches.length > 1) {
throw new Error(`'ec2-instance-type' mixes architectures (${types.join(', ')}); all types in a fallback list must share one architecture`);
}
if (types.length > 0 && !arches.includes(arch)) {
throw new Error(`'ec2-instance-type' (${types.join(', ')}) looks like ${arches[0]} but 'architecture' is '${arch}'`);
}
}

generateUniqueLabel() {
return Math.random().toString(36).substr(2, 5);
}
Expand Down Expand Up @@ -106071,9 +106120,26 @@ function parseCsv(value) {
return value.split(',').map((v) => v.trim()).filter((v) => v.length > 0);
}

// Infer whether an EC2 instance type is arm64/Graviton from its name. AWS
// Graviton families carry a 'g' as the processor letter after the
// generation digit (c7g, m6gd, t4g, x2gd, im4gn, is4gen, hpc7g, g5g), plus
// the first-gen a1. Everything else is treated as x64. Name-based heuristic
// (no API call) — used only to reject obviously mixed-arch fallback lists.
function isArmInstanceType(instanceType) {
const family = String(instanceType).split('.')[0];
return /\dg[a-z]*$/.test(family) || family === 'a1';
}

// The architecture ('x64' | 'arm64') implied by an instance type name.
function instanceArch(instanceType) {
return isArmInstanceType(instanceType) ? 'arm64' : 'x64';
}

module.exports = {
sortByCreationDate,
parseCsv,
isArmInstanceType,
instanceArch,
}


Expand Down
28 changes: 28 additions & 0 deletions src/aws.js
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,20 @@ const TRANSIENT_ERROR_CODES = new Set([
'Unavailable',
]);

// Map the action's `architecture` input to the AMI Architecture value that
// DescribeImages reports.
const AMI_ARCH_BY_INPUT = { x64: 'x86_64', arm64: 'arm64' };

// Compare an AMI's reported Architecture against the requested architecture.
// Returns true (match), false (mismatch — fail fast), or null (unknown —
// the AMI didn't report an architecture; caller warns and continues).
function matchAmiArchitecture(imageArchitecture, architecture) {
if (!imageArchitecture) {
return null;
}
return imageArchitecture === AMI_ARCH_BY_INPUT[architecture];
}

function classifyRunError(error) {
const name = error && error.name;
if (CAPACITY_ERROR_CODES.has(name)) {
Expand Down Expand Up @@ -485,6 +499,19 @@ async function startEc2Instance(label, githubRegistrationToken) {
const resolved = await resolveImage(client);
config.input.ec2ImageId = resolved.id;

// Fail fast on an AMI/architecture mismatch — the classic silent failure
// (an x64 tarball on an arm64 box, or vice versa) becomes a clear error
// in seconds instead of a registration timeout.
const amiArchMatch = matchAmiArchitecture(resolved.image.Architecture, config.input.architecture);
if (amiArchMatch === false) {
throw new Error(
`AMI ${resolved.id} is ${resolved.image.Architecture}, but 'architecture' is '${config.input.architecture}' ` +
`(expected ${AMI_ARCH_BY_INPUT[config.input.architecture]}). Point at an AMI matching the architecture, or fix the input.`,
);
} else if (amiArchMatch === null) {
log.warn('ami_architecture', { applied: false, reason: 'AMI did not report an architecture — skipping arch validation', ami_id: resolved.id });
}

// InstanceType and SubnetId are injected per attempt by the fallback
// chain (see below), so they are intentionally absent from the base.
const params = {
Expand Down Expand Up @@ -777,6 +804,7 @@ module.exports = {
listManagedInstances,
// Exported for unit testing.
classifyRunError,
matchAmiArchitecture,
launchWithFallback,
launchAcrossMarkets,
buildMarketOptions,
Expand Down
21 changes: 21 additions & 0 deletions src/config.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
const core = require('@actions/core');
const github = require('@actions/github');
const { parseCsv, instanceArch } = require('./utils');

class Config {
constructor() {
Expand All @@ -20,6 +21,7 @@ class Config {
ec2InstanceId: core.getInput('ec2-instance-id'),
iamRoleName: core.getInput('iam-role-name'),
runnerVersion: core.getInput('runner-version') || '2.335.1',
architecture: core.getInput('architecture') || 'x64',
httpTokens: core.getInput('http-tokens') || 'required',
encryptEbs: core.getInput('encrypt-ebs') || 'false',
volumeSize: core.getInput('volume-size'),
Expand Down Expand Up @@ -68,6 +70,7 @@ class Config {
}
this.validateVolumeInputs();
this.validateMarketInputs();
this.validateArchitectureInputs();
} else if (this.input.mode === 'stop') {
if (!this.input.label || !this.input.ec2InstanceId) {
throw new Error(`Not all the required inputs are provided for the 'stop' mode`);
Expand Down Expand Up @@ -126,6 +129,24 @@ class Config {
}
}

// Validate the architecture input and that the ec2-instance-type fallback
// list is single-architecture and consistent with it. Placement and arch
// are kept orthogonal: a fallback chain must not mix arm64 and x64 types.
validateArchitectureInputs() {
const arch = this.input.architecture;
if (!['x64', 'arm64'].includes(arch)) {
throw new Error(`'architecture' must be one of: x64, arm64`);
}
const types = parseCsv(this.input.ec2InstanceType);
const arches = [...new Set(types.map(instanceArch))];
if (arches.length > 1) {
throw new Error(`'ec2-instance-type' mixes architectures (${types.join(', ')}); all types in a fallback list must share one architecture`);
}
if (types.length > 0 && !arches.includes(arch)) {
throw new Error(`'ec2-instance-type' (${types.join(', ')}) looks like ${arches[0]} but 'architecture' is '${arch}'`);
}
}

generateUniqueLabel() {
return Math.random().toString(36).substr(2, 5);
}
Expand Down
17 changes: 17 additions & 0 deletions src/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,24 @@ function parseCsv(value) {
return value.split(',').map((v) => v.trim()).filter((v) => v.length > 0);
}

// Infer whether an EC2 instance type is arm64/Graviton from its name. AWS
// Graviton families carry a 'g' as the processor letter after the
// generation digit (c7g, m6gd, t4g, x2gd, im4gn, is4gen, hpc7g, g5g), plus
// the first-gen a1. Everything else is treated as x64. Name-based heuristic
// (no API call) — used only to reject obviously mixed-arch fallback lists.
function isArmInstanceType(instanceType) {
const family = String(instanceType).split('.')[0];
return /\dg[a-z]*$/.test(family) || family === 'a1';
}

// The architecture ('x64' | 'arm64') implied by an instance type name.
function instanceArch(instanceType) {
return isArmInstanceType(instanceType) ? 'arm64' : 'x64';
}

module.exports = {
sortByCreationDate,
parseCsv,
isArmInstanceType,
instanceArch,
}
31 changes: 31 additions & 0 deletions tests/arch.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
// Tests for AMI architecture validation (matchAmiArchitecture). config + core
// are mocked because aws.js reaches log.js at require time.
jest.mock('../src/config', () => ({
input: { mode: 'start', debug: 'false' },
githubContext: { owner: 'o', repo: 'r' },
}));
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(),
}));

const { matchAmiArchitecture } = require('../src/aws');

describe('matchAmiArchitecture', () => {
test('x86_64 AMI matches x64', () => {
expect(matchAmiArchitecture('x86_64', 'x64')).toBe(true);
});
test('arm64 AMI matches arm64', () => {
expect(matchAmiArchitecture('arm64', 'arm64')).toBe(true);
});
test('x86_64 AMI mismatches arm64', () => {
expect(matchAmiArchitecture('x86_64', 'arm64')).toBe(false);
});
test('arm64 AMI mismatches x64', () => {
expect(matchAmiArchitecture('arm64', 'x64')).toBe(false);
});
test('unknown/absent AMI architecture returns null (warn-and-continue)', () => {
expect(matchAmiArchitecture(undefined, 'x64')).toBeNull();
expect(matchAmiArchitecture('', 'arm64')).toBeNull();
});
});
28 changes: 28 additions & 0 deletions tests/config.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,34 @@ describe('Config — root volume inputs', () => {
});
});

describe('Config — architecture input', () => {
test('defaults to x64', () => {
expect(loadConfig(startModeInputs).input.architecture).toBe('x64');
});

test('accepts arm64 with a Graviton instance type', () => {
const config = loadConfig({ ...startModeInputs, 'architecture': 'arm64', 'ec2-instance-type': 'c7g.4xlarge' });
expect(config.input.architecture).toBe('arm64');
});

test('accepts an arm64 fallback list of Graviton types', () => {
const config = loadConfig({ ...startModeInputs, 'architecture': 'arm64', 'ec2-instance-type': 'c7g.4xlarge,c6g.4xlarge,m7g.4xlarge' });
expect(config.input.architecture).toBe('arm64');
});

test('rejects an invalid architecture', () => {
expectValidationFailure({ ...startModeInputs, 'architecture': 'x86' }, /'architecture' must be one of/);
});

test('rejects a mixed-architecture instance-type list', () => {
expectValidationFailure({ ...startModeInputs, 'ec2-instance-type': 'c7g.4xlarge,c7i.4xlarge' }, /mixes architectures/);
});

test('rejects an instance type whose arch conflicts with the architecture input', () => {
expectValidationFailure({ ...startModeInputs, 'architecture': 'arm64', 'ec2-instance-type': 'c7i.4xlarge' }, /but 'architecture' is 'arm64'/);
});
});

describe('Config — spot / market inputs', () => {
test('defaults to on-demand with default fallback', () => {
const config = loadConfig(startModeInputs);
Expand Down
18 changes: 17 additions & 1 deletion tests/utils.test.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,20 @@
const { sortByCreationDate, parseCsv } = require('../src/utils');
const { sortByCreationDate, parseCsv, isArmInstanceType, instanceArch } = require('../src/utils');

describe('isArmInstanceType / instanceArch', () => {
test('recognizes Graviton families as arm64', () => {
for (const t of ['c7g.4xlarge', 'm6g.large', 't4g.small', 'c7gd.2xlarge', 'c7gn.xlarge', 'x2gd.medium', 'im4gn.large', 'is4gen.large', 'a1.large', 'hpc7g.16xlarge']) {
expect(isArmInstanceType(t)).toBe(true);
expect(instanceArch(t)).toBe('arm64');
}
});

test('treats Intel/AMD families as x64', () => {
for (const t of ['c7i.4xlarge', 'c6a.large', 'm5.xlarge', 't3.medium', 'c5n.2xlarge', 'r5.large', 'g5.xlarge']) {
expect(isArmInstanceType(t)).toBe(false);
expect(instanceArch(t)).toBe('x64');
}
});
});

describe('parseCsv', () => {
test('splits and trims a comma-separated list', () => {
Expand Down