diff --git a/README.md b/README.md
index 79c48ffa..b6e67983 100644
--- a/README.md
+++ b/README.md
@@ -237,7 +237,7 @@ Now you're ready to go!
| `mode` | Always required. | Specify here which mode you want to use:
- `start` - to start a new runner;
- `stop` - to stop the previously created runner. |
| `github-token` | Always required. | GitHub Personal Access Token with the `repo` scope assigned. |
| `ec2-image-id` | Required if you use the `start` mode and don't provide `availability-zones-config`. | EC2 Image Id (AMI). The new runner will be launched from this image. Compatible with Amazon Linux 2, Amazon Linux 2023, and Ubuntu images. |
-| `ec2-instance-type` | Required if you use the `start` mode. | EC2 Instance Type. |
+| `ec2-instance-type` | Required if you use the `start` mode. | EC2 Instance Type. Accepts a single type (e.g. `t3.micro`) or a JSON array of types (e.g. `'["t3.micro", "t3.small", "m5.large"]'`). When multiple types are specified, the action tries each in order until one succeeds. Useful for spot instances where capacity may vary by type. |
| `subnet-id` | Required if you use the `start` mode and don't provide `availability-zones-config`. | VPC Subnet Id. The subnet should belong to the same VPC as the specified security group. |
| `security-group-id` | Required if you use the `start` mode and don't provide `availability-zones-config`. | EC2 Security Group Id. The security group should belong to the same VPC as the specified subnet. Only outbound traffic for port 443 is required. No inbound traffic is required. |
| `label` | Required if you use the `stop` mode. | Name of the unique label assigned to the runner. The label is provided by the output of the action in the `start` mode. |
@@ -400,6 +400,26 @@ Each configuration object requires `imageId`, `subnetId`, and `securityGroupId`.
]
```
+### Advanced: Multiple instance types
+
+When using spot instances, a specific instance type may not have available capacity. By specifying multiple instance types as a JSON array, the action will try each type in order until one succeeds. This is especially powerful when combined with multi-AZ failover — the action tries all instance types within each AZ before moving to the next AZ.
+
+```yml
+ - name: Start EC2 runner
+ id: start-ec2-runner
+ uses: machulav/ec2-github-runner@v2
+ with:
+ mode: start
+ github-token: ${{ secrets.GH_PERSONAL_ACCESS_TOKEN }}
+ ec2-image-id: ami-123
+ ec2-instance-type: '["c5.xlarge", "c5a.xlarge", "c5d.xlarge", "m5.xlarge"]'
+ subnet-id: subnet-123
+ security-group-id: sg-123
+ market-type: spot
+```
+
+> **Tip:** Choose instance types with similar vCPU/memory specs so your workload runs consistently regardless of which type is selected.
+
### Advanced: Debug mode
When a runner fails to register, it can be difficult to diagnose the issue because user-data scripts execute on the remote EC2 instance. The `runner-debug` input enables verbose logging to help with troubleshooting.
diff --git a/action.yml b/action.yml
index 3f4f12c2..d6596da2 100644
--- a/action.yml
+++ b/action.yml
@@ -32,7 +32,10 @@ inputs:
required: false
ec2-instance-type:
description: >-
- EC2 Instance Type.
+ EC2 Instance Type. Accepts a single instance type (e.g. 't3.micro') or a JSON array
+ of instance types (e.g. '["t3.micro", "t3.small", "m5.large"]').
+ When multiple types are provided, the action tries each type in order until one succeeds.
+ This is especially useful with spot instances where a specific instance type may not have capacity.
This input is required if you use the 'start' mode.
required: false
subnet-id:
diff --git a/dist/index.js b/dist/index.js
index b0e1851a..ae6c61d0 100644
--- a/dist/index.js
+++ b/dist/index.js
@@ -145221,7 +145221,7 @@ function buildMarketOptions() {
};
}
-async function createEc2InstanceWithParams(imageId, subnetId, securityGroupId, label, githubRegistrationToken, region, encodedJitConfig) {
+async function createEc2InstanceWithParams(imageId, subnetId, securityGroupId, label, githubRegistrationToken, region, encodedJitConfig, instanceType) {
// Region is always specified now, so we can directly use it
const ec2ClientOptions = { region };
const ec2 = new EC2Client(ec2ClientOptions);
@@ -145230,7 +145230,7 @@ async function createEc2InstanceWithParams(imageId, subnetId, securityGroupId, l
const params = {
ImageId: imageId,
- InstanceType: config.input.ec2InstanceType,
+ InstanceType: instanceType,
MaxCount: 1,
MinCount: 1,
SecurityGroupIds: [securityGroupId],
@@ -145264,7 +145264,8 @@ async function createEc2InstanceWithParams(imageId, subnetId, securityGroupId, l
}
async function startEc2Instance(label, githubRegistrationToken, encodedJitConfig) {
- core.info(`Attempting to start EC2 instance using ${config.availabilityZones.length} availability zone configuration(s)`);
+ const instanceTypes = config.input.ec2InstanceTypes;
+ core.info(`Attempting to start EC2 instance using ${config.availabilityZones.length} availability zone configuration(s) and ${instanceTypes.length} instance type(s): ${instanceTypes.join(', ')}`);
const errors = [];
@@ -145276,32 +145277,35 @@ async function startEc2Instance(label, githubRegistrationToken, encodedJitConfig
core.info(`Trying availability zone configuration ${i + 1}/${config.availabilityZones.length}`);
core.info(`Using imageId: ${azConfig.imageId}, subnetId: ${azConfig.subnetId}, securityGroupId: ${azConfig.securityGroupId}, region: ${region}`);
- try {
- const ec2InstanceId = await createEc2InstanceWithParams(
- azConfig.imageId,
- azConfig.subnetId,
- azConfig.securityGroupId,
- label,
- githubRegistrationToken,
- region,
- encodedJitConfig
- );
-
- core.info(`Successfully started AWS EC2 instance ${ec2InstanceId} using availability zone configuration ${i + 1} in region ${region}`);
- return { ec2InstanceId, region };
- } catch (error) {
- const errorMessage = `Failed to start EC2 instance with configuration ${i + 1} in region ${region}: ${error.message}`;
- core.warning(errorMessage);
- errors.push(errorMessage);
+ // Try each instance type within this AZ configuration
+ for (const instanceType of instanceTypes) {
+ core.info(`Trying instance type: ${instanceType}`);
+ try {
+ const ec2InstanceId = await createEc2InstanceWithParams(
+ azConfig.imageId,
+ azConfig.subnetId,
+ azConfig.securityGroupId,
+ label,
+ githubRegistrationToken,
+ region,
+ encodedJitConfig,
+ instanceType
+ );
- // Continue to the next availability zone configuration
- continue;
+ core.info(`Successfully started AWS EC2 instance ${ec2InstanceId} (type: ${instanceType}) using availability zone configuration ${i + 1} in region ${region}`);
+ return { ec2InstanceId, region };
+ } catch (error) {
+ const errorMessage = `Failed to start EC2 instance (type: ${instanceType}) with AZ configuration ${i + 1} in region ${region}: ${error.message}`;
+ core.warning(errorMessage);
+ errors.push(errorMessage);
+ continue;
+ }
}
}
- // If we've tried all configurations and none worked, throw an error
- core.error('All availability zone configurations failed');
- throw new Error(`Failed to start EC2 instance in any availability zone. Errors: ${errors.join('; ')}`);
+ // If we've tried all configurations and instance types and none worked, throw an error
+ core.error('All availability zone configurations and instance types failed');
+ throw new Error(`Failed to start EC2 instance with any configuration. Errors: ${errors.join('; ')}`);
}
async function terminateEc2Instance() {
@@ -145409,6 +145413,7 @@ class Config {
ec2ImageId: core.getInput('ec2-image-id'),
ec2InstanceId: core.getInput('ec2-instance-id'),
ec2InstanceType: core.getInput('ec2-instance-type'),
+ ec2InstanceTypes: [],
githubToken: core.getInput('github-token'),
iamRoleName: core.getInput('iam-role-name'),
label: core.getInput('label'),
@@ -145506,6 +145511,28 @@ class Config {
throw new Error(`The 'ec2-instance-type' input is required for the 'start' mode.`);
}
+ // Parse ec2-instance-type: supports a single string or a JSON array of strings
+ const rawType = this.input.ec2InstanceType.trim();
+ if (rawType.startsWith('[')) {
+ try {
+ this.input.ec2InstanceTypes = JSON.parse(rawType);
+ if (!Array.isArray(this.input.ec2InstanceTypes) || this.input.ec2InstanceTypes.length === 0) {
+ throw new Error('must be a non-empty JSON array of strings');
+ }
+ for (const t of this.input.ec2InstanceTypes) {
+ if (typeof t !== 'string' || t.trim().length === 0) {
+ throw new Error('each element must be a non-empty string');
+ }
+ }
+ } catch (error) {
+ throw new Error(`Invalid 'ec2-instance-type' input: ${error.message}`);
+ }
+ } else {
+ this.input.ec2InstanceTypes = [rawType];
+ }
+ // Keep ec2InstanceType pointing to the first type for backward compatibility
+ this.input.ec2InstanceType = this.input.ec2InstanceTypes[0];
+
// If no availability zones config provided, check for individual parameters
if (this.availabilityZones.length === 0) {
if (!this.input.ec2ImageId || !this.input.subnetId || !this.input.securityGroupId) {
diff --git a/src/__tests__/jit.test.js b/src/__tests__/jit.test.js
index c114d2fe..c651d9bc 100644
--- a/src/__tests__/jit.test.js
+++ b/src/__tests__/jit.test.js
@@ -291,6 +291,47 @@ describe('aws.js - runner-debug', () => {
});
});
+describe('Config - ec2-instance-type parsing', () => {
+ beforeEach(() => {
+ process.env.AWS_REGION = 'us-east-1';
+ });
+
+ test('parses single instance type string into array', () => {
+ setupInputs({ 'ec2-instance-type': 't3.micro' });
+ const config = createConfig();
+ expect(config.input.ec2InstanceTypes).toEqual(['t3.micro']);
+ expect(config.input.ec2InstanceType).toBe('t3.micro');
+ });
+
+ test('parses JSON array of instance types', () => {
+ setupInputs({ 'ec2-instance-type': '["t3.micro", "t3.small", "m5.large"]' });
+ const config = createConfig();
+ expect(config.input.ec2InstanceTypes).toEqual(['t3.micro', 't3.small', 'm5.large']);
+ expect(config.input.ec2InstanceType).toBe('t3.micro');
+ });
+
+ test('throws on empty JSON array', () => {
+ setupInputs({ 'ec2-instance-type': '[]' });
+ expect(() => createConfig()).toThrow("Invalid 'ec2-instance-type' input");
+ });
+
+ test('throws on invalid JSON array content', () => {
+ setupInputs({ 'ec2-instance-type': '[123, 456]' });
+ expect(() => createConfig()).toThrow("Invalid 'ec2-instance-type' input");
+ });
+
+ test('throws on malformed JSON', () => {
+ setupInputs({ 'ec2-instance-type': '[not json' });
+ expect(() => createConfig()).toThrow("Invalid 'ec2-instance-type' input");
+ });
+
+ test('handles whitespace around single type', () => {
+ setupInputs({ 'ec2-instance-type': ' t3.micro ' });
+ const config = createConfig();
+ expect(config.input.ec2InstanceTypes).toEqual(['t3.micro']);
+ });
+});
+
describe('Config - runner-debug input', () => {
beforeEach(() => {
process.env.AWS_REGION = 'us-east-1';
diff --git a/src/aws.js b/src/aws.js
index effbfc0b..e24382e3 100644
--- a/src/aws.js
+++ b/src/aws.js
@@ -213,7 +213,7 @@ function buildMarketOptions() {
};
}
-async function createEc2InstanceWithParams(imageId, subnetId, securityGroupId, label, githubRegistrationToken, region, encodedJitConfig) {
+async function createEc2InstanceWithParams(imageId, subnetId, securityGroupId, label, githubRegistrationToken, region, encodedJitConfig, instanceType) {
// Region is always specified now, so we can directly use it
const ec2ClientOptions = { region };
const ec2 = new EC2Client(ec2ClientOptions);
@@ -222,7 +222,7 @@ async function createEc2InstanceWithParams(imageId, subnetId, securityGroupId, l
const params = {
ImageId: imageId,
- InstanceType: config.input.ec2InstanceType,
+ InstanceType: instanceType,
MaxCount: 1,
MinCount: 1,
SecurityGroupIds: [securityGroupId],
@@ -256,7 +256,8 @@ async function createEc2InstanceWithParams(imageId, subnetId, securityGroupId, l
}
async function startEc2Instance(label, githubRegistrationToken, encodedJitConfig) {
- core.info(`Attempting to start EC2 instance using ${config.availabilityZones.length} availability zone configuration(s)`);
+ const instanceTypes = config.input.ec2InstanceTypes;
+ core.info(`Attempting to start EC2 instance using ${config.availabilityZones.length} availability zone configuration(s) and ${instanceTypes.length} instance type(s): ${instanceTypes.join(', ')}`);
const errors = [];
@@ -268,32 +269,35 @@ async function startEc2Instance(label, githubRegistrationToken, encodedJitConfig
core.info(`Trying availability zone configuration ${i + 1}/${config.availabilityZones.length}`);
core.info(`Using imageId: ${azConfig.imageId}, subnetId: ${azConfig.subnetId}, securityGroupId: ${azConfig.securityGroupId}, region: ${region}`);
- try {
- const ec2InstanceId = await createEc2InstanceWithParams(
- azConfig.imageId,
- azConfig.subnetId,
- azConfig.securityGroupId,
- label,
- githubRegistrationToken,
- region,
- encodedJitConfig
- );
-
- core.info(`Successfully started AWS EC2 instance ${ec2InstanceId} using availability zone configuration ${i + 1} in region ${region}`);
- return { ec2InstanceId, region };
- } catch (error) {
- const errorMessage = `Failed to start EC2 instance with configuration ${i + 1} in region ${region}: ${error.message}`;
- core.warning(errorMessage);
- errors.push(errorMessage);
-
- // Continue to the next availability zone configuration
- continue;
+ // Try each instance type within this AZ configuration
+ for (const instanceType of instanceTypes) {
+ core.info(`Trying instance type: ${instanceType}`);
+ try {
+ const ec2InstanceId = await createEc2InstanceWithParams(
+ azConfig.imageId,
+ azConfig.subnetId,
+ azConfig.securityGroupId,
+ label,
+ githubRegistrationToken,
+ region,
+ encodedJitConfig,
+ instanceType
+ );
+
+ core.info(`Successfully started AWS EC2 instance ${ec2InstanceId} (type: ${instanceType}) using availability zone configuration ${i + 1} in region ${region}`);
+ return { ec2InstanceId, region };
+ } catch (error) {
+ const errorMessage = `Failed to start EC2 instance (type: ${instanceType}) with AZ configuration ${i + 1} in region ${region}: ${error.message}`;
+ core.warning(errorMessage);
+ errors.push(errorMessage);
+ continue;
+ }
}
}
- // If we've tried all configurations and none worked, throw an error
- core.error('All availability zone configurations failed');
- throw new Error(`Failed to start EC2 instance in any availability zone. Errors: ${errors.join('; ')}`);
+ // If we've tried all configurations and instance types and none worked, throw an error
+ core.error('All availability zone configurations and instance types failed');
+ throw new Error(`Failed to start EC2 instance with any configuration. Errors: ${errors.join('; ')}`);
}
async function terminateEc2Instance() {
diff --git a/src/config.js b/src/config.js
index 1892a123..21cb18bf 100644
--- a/src/config.js
+++ b/src/config.js
@@ -7,6 +7,7 @@ class Config {
ec2ImageId: core.getInput('ec2-image-id'),
ec2InstanceId: core.getInput('ec2-instance-id'),
ec2InstanceType: core.getInput('ec2-instance-type'),
+ ec2InstanceTypes: [],
githubToken: core.getInput('github-token'),
iamRoleName: core.getInput('iam-role-name'),
label: core.getInput('label'),
@@ -104,6 +105,28 @@ class Config {
throw new Error(`The 'ec2-instance-type' input is required for the 'start' mode.`);
}
+ // Parse ec2-instance-type: supports a single string or a JSON array of strings
+ const rawType = this.input.ec2InstanceType.trim();
+ if (rawType.startsWith('[')) {
+ try {
+ this.input.ec2InstanceTypes = JSON.parse(rawType);
+ if (!Array.isArray(this.input.ec2InstanceTypes) || this.input.ec2InstanceTypes.length === 0) {
+ throw new Error('must be a non-empty JSON array of strings');
+ }
+ for (const t of this.input.ec2InstanceTypes) {
+ if (typeof t !== 'string' || t.trim().length === 0) {
+ throw new Error('each element must be a non-empty string');
+ }
+ }
+ } catch (error) {
+ throw new Error(`Invalid 'ec2-instance-type' input: ${error.message}`);
+ }
+ } else {
+ this.input.ec2InstanceTypes = [rawType];
+ }
+ // Keep ec2InstanceType pointing to the first type for backward compatibility
+ this.input.ec2InstanceType = this.input.ec2InstanceTypes[0];
+
// If no availability zones config provided, check for individual parameters
if (this.availabilityZones.length === 0) {
if (!this.input.ec2ImageId || !this.input.subnetId || !this.input.securityGroupId) {