diff --git a/README.md b/README.md
index eee8249b..96e18584 100644
--- a/README.md
+++ b/README.md
@@ -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.
This field is a stringified JSON array of tag objects, each containing a `Key` and `Value` field (see example below).
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`).
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`).
- `required` — IMDSv2 only; mitigates SSRF-style credential theft.
- `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). |
@@ -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:
diff --git a/action.yml b/action.yml
index 60a3dccf..634d9c4a 100644
--- a/action.yml
+++ b/action.yml
@@ -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
diff --git a/dist/index.js b/dist/index.js
index 172e3c14..987b0ded 100644
--- a/dist/index.js
+++ b/dist/index.js
@@ -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)) {
@@ -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 = {
@@ -105427,6 +105454,7 @@ module.exports = {
listManagedInstances,
// Exported for unit testing.
classifyRunError,
+ matchAmiArchitecture,
launchWithFallback,
launchAcrossMarkets,
buildMarketOptions,
@@ -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() {
@@ -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'),
@@ -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`);
@@ -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);
}
@@ -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,
}
diff --git a/src/aws.js b/src/aws.js
index 07d8c49e..362a03bc 100644
--- a/src/aws.js
+++ b/src/aws.js
@@ -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)) {
@@ -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 = {
@@ -777,6 +804,7 @@ module.exports = {
listManagedInstances,
// Exported for unit testing.
classifyRunError,
+ matchAmiArchitecture,
launchWithFallback,
launchAcrossMarkets,
buildMarketOptions,
diff --git a/src/config.js b/src/config.js
index 9be09e83..933ac417 100644
--- a/src/config.js
+++ b/src/config.js
@@ -1,5 +1,6 @@
const core = require('@actions/core');
const github = require('@actions/github');
+const { parseCsv, instanceArch } = require('./utils');
class Config {
constructor() {
@@ -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'),
@@ -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`);
@@ -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);
}
diff --git a/src/utils.js b/src/utils.js
index 0f8de412..137beaff 100644
--- a/src/utils.js
+++ b/src/utils.js
@@ -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,
}
diff --git a/tests/arch.test.js b/tests/arch.test.js
new file mode 100644
index 00000000..28dea639
--- /dev/null
+++ b/tests/arch.test.js
@@ -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();
+ });
+});
diff --git a/tests/config.test.js b/tests/config.test.js
index e1373b95..f28b704a 100644
--- a/tests/config.test.js
+++ b/tests/config.test.js
@@ -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);
diff --git a/tests/utils.test.js b/tests/utils.test.js
index 3abf9e6e..f4d5db2b 100644
--- a/tests/utils.test.js
+++ b/tests/utils.test.js
@@ -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', () => {