From 4da1f8eddee1258aa18a8c1c50bd349e21a007c4 Mon Sep 17 00:00:00 2001 From: kurok <22548029+kurok@users.noreply.github.com> Date: Thu, 2 Jul 2026 20:15:05 +0100 Subject: [PATCH] feat: ARM64/Graviton support (architecture input + AMI-arch validation) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Graviton (c7g/m7g/r7g) is ~20-40% better price/performance for CI compile/test workloads. The runtime bootstrap already selects the tarball by uname and the checksum table already carries both arches, so the missing pieces were guard rails, not plumbing: - New 'architecture' input: x64 (default) | arm64. - AMI-arch fail-fast: at start the resolved AMI's Architecture (from DescribeImages) is checked against the input — a mismatch (x64 AMI with architecture: arm64, or vice versa) fails in seconds with a clear message instead of a cryptic bootstrap/registration timeout. This fixes today's silent-failure mode for anyone pointing the action at an arm64 AMI. An AMI that reports no architecture warns and continues. - Mixed-arch fallback lists rejected at config parse: all types in an ec2-instance-type list must share one architecture (name heuristic via utils.instanceArch), and must match the architecture input. Placement and arch stay orthogonal. - Default x64 behavior and generated user-data are unchanged (regression). Tests: matchAmiArchitecture matrix (match/mismatch/unknown), isArmInstanceType/ instanceArch (Graviton vs Intel/AMD families), config validation (invalid arch, mixed list, arch/type conflict) — 179 tests total. README "Running on Graviton" section with AL2023 arm64 AMI + instance-type examples; action.yml description. Note: end-to-end on a real Graviton instance is a manual checklist item (can't provision EC2 in CI); generation + validation are covered by unit tests. Closes #43 Signed-off-by: kurok <22548029+kurok@users.noreply.github.com> --- README.md | 19 +++++++++++++ action.yml | 10 +++++++ dist/index.js | 66 ++++++++++++++++++++++++++++++++++++++++++++ src/aws.js | 28 +++++++++++++++++++ src/config.js | 21 ++++++++++++++ src/utils.js | 17 ++++++++++++ tests/arch.test.js | 31 +++++++++++++++++++++ tests/config.test.js | 28 +++++++++++++++++++ tests/utils.test.js | 18 +++++++++++- 9 files changed, 237 insertions(+), 1 deletion(-) create mode 100644 tests/arch.test.js 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', () => {