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
23 changes: 23 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -310,6 +310,9 @@ Now you're ready to go!
| `ec2-instance-type` | Required if you use the `start` mode. | EC2 Instance Type. <br><br> Accepts a comma-separated **ordered fallback list** (e.g. `c7i.4xlarge,c6i.4xlarge,m7i.4xlarge`) — see [Capacity fallback](#capacity-fallback-across-azs-and-instance-types). A single value behaves as before. |
| `subnet-id` | Required if you use the `start` mode. | VPC Subnet Id. <br><br> The subnet should belong to the same VPC as the specified security group. <br><br> Accepts a comma-separated **ordered fallback list** of subnets (typically across AZs), e.g. `subnet-aaa,subnet-bbb`. |
| `security-group-id` | Required if you use the `start` mode. | EC2 Security Group Id. <br><br> The security group should belong to the same VPC as the specified subnet. <br><br> Only the outbound traffic for port 443 should be allowed. No inbound traffic is required. |
| `market-type` | Optional. Used only with the `start` mode. | `on-demand` (default) or `spot`. Spot is typically 60–90% cheaper. See [Saving costs with spot](#saving-costs-with-spot). |
| `spot-fallback` | Optional. Used only with `start` + `market-type: spot`. | What to do when spot capacity is unavailable: `on-demand` (default) retries the launch on-demand; `fail` surfaces the error. |
| `spot-max-price` | Optional. Used only with `start` + `market-type: spot`. | Max spot price in USD/hour (e.g. `0.05`). Empty (default) caps at the on-demand price. |
| `label` | Required if you use the `stop` mode. | Name of the unique label assigned to the runner. <br><br> The label is provided by the output of the action in the `start` mode. <br><br> The label is used to remove the runner from GitHub when the runner is not needed anymore. |
| `ec2-instance-id` | Required if you use the `stop` mode. | EC2 Instance Id of the created runner. <br><br> The id is provided by the output of the action in the `start` mode. <br><br> The id is used to terminate the EC2 instance when the runner is not needed anymore. |
| `iam-role-name` | Optional. Used only with the `start` mode. | IAM role name to attach to the created EC2 runner. <br><br> This allows the runner to have permissions to run additional actions within the AWS account, without having to manage additional GitHub secrets and AWS users. <br><br> Setting this requires additional AWS permissions for the role launching the instance (see above). |
Expand Down Expand Up @@ -347,6 +350,7 @@ We recommend using [aws-actions/configure-aws-credentials](https://github.com/aw
| `ec2-instance-id` | EC2 Instance Id of the created runner. <br><br> The id is used to terminate the EC2 instance when the runner is not needed anymore. |
| `instance-type-used` | The EC2 instance type actually launched. With a [capacity-fallback](#capacity-fallback-across-azs-and-instance-types) list this may differ from your first choice. |
| `subnet-id-used` | The subnet the runner was actually launched into. With a capacity-fallback list this may differ from your first choice. |
| `market-type-used` | The market the runner launched in: `spot` or `on-demand`. Differs from `market-type` when spot fell back to on-demand. |

### Example

Expand Down Expand Up @@ -421,6 +425,25 @@ In [this discussion](https://github.com/machulav/ec2-github-runner/discussions/1

If you use this action in your workflow, feel free to add your story there as well 🙌

## Saving costs with spot

CI runners are a textbook spot workload — short-lived, ephemeral (registered with `--ephemeral`), and restartable — and spot pricing is typically 60–90% below on-demand. Opt in with `market-type: spot`:

```yml
- name: Start EC2 runner
uses: namecheap/ec2-github-runner@v3
with:
mode: start
# ... other inputs ...
market-type: spot
spot-fallback: on-demand # default — retry on-demand if spot is unavailable
# spot-max-price: '0.05' # optional cap; default is the on-demand price
```

The request is a **one-time** spot request with `InstanceInterruptionBehavior: terminate`, so nothing persistent is left to leak and `stop` mode terminates it identically to an on-demand instance.

**Interruption trade-off:** a spot runner can be reclaimed mid-job with a 2-minute warning. Because runners register as `--ephemeral`, an interrupted runner auto-deregisters — the job fails visibly and re-runs cleanly rather than hanging. Prefer spot for retry-safe jobs. If spot capacity is unavailable at launch, `spot-fallback: on-demand` (the default) transparently launches on-demand instead; set `spot-fallback: fail` for cost-strict pipelines that must never pay on-demand. The `market-type-used` output reports which market actually launched. Spot composes with the [capacity fallback](#capacity-fallback-across-azs-and-instance-types) below: the whole type × subnet chain is tried on spot first, then again on-demand.

## Capacity fallback across AZs and instance types

A single `subnet-id` + `ec2-instance-type` means a single point of failure: when that AZ has no capacity for that type — routine for larger/GPU types — `RunInstances` fails and the whole workflow fails with it. Pass comma-separated ordered lists and the action walks them until a launch succeeds:
Expand Down
25 changes: 25 additions & 0 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,27 @@ inputs:
this — opt in explicitly when you've verified the permissions.
required: false
default: 'false'
market-type:
description: >-
Used only with the 'start' mode. 'on-demand' (default) or 'spot'.
Spot instances are typically 60-90% cheaper and fit the ephemeral
runner model, but can be reclaimed mid-job (2-minute warning).
required: false
default: 'on-demand'
spot-fallback:
description: >-
Used only with the 'start' mode when market-type is 'spot'. What to
do when spot capacity is unavailable: 'on-demand' (default) retries
the launch as on-demand; 'fail' surfaces the error for cost-strict
pipelines.
required: false
default: 'on-demand'
spot-max-price:
description: >-
Used only with the 'start' mode when market-type is 'spot'. Optional
maximum spot price in USD/hour (e.g. '0.05'). Empty (default) uses the
on-demand price as the cap (AWS default).
required: false
volume-size:
description: >-
Used only with the 'start' mode. Root EBS volume size in GiB. When
Expand Down Expand Up @@ -206,6 +227,10 @@ outputs:
description: >-
The subnet the runner was actually launched into (start mode). With a
capacity-fallback list this may differ from the first choice.
market-type-used:
description: >-
The market the runner was actually launched in (start mode): 'spot' or
'on-demand'. Differs from market-type when spot fell back to on-demand.
runs:
using: node24
main: ./dist/index.js
105 changes: 98 additions & 7 deletions dist/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -104739,6 +104739,61 @@ async function launchWithFallback(attempt, instanceTypes, subnetIds) {
throw error;
}

// Build the RunInstances InstanceMarketOptions for a spot launch, or
// undefined for on-demand (so on-demand params are byte-identical to
// before spot support). One-time requests fit the launch-use-terminate
// lifecycle — no persistent spot request is left to leak — and the
// interruption behavior is terminate to match the ephemeral runner model.
function buildMarketOptions(marketType, spotMaxPrice) {
if (marketType !== 'spot') {
return undefined;
}
const spotOptions = {
SpotInstanceType: 'one-time',
InstanceInterruptionBehavior: 'terminate',
};
if (spotMaxPrice) {
spotOptions.MaxPrice = String(spotMaxPrice);
}
return { MarketType: 'spot', SpotOptions: spotOptions };
}

// The ordered list of market types to try. on-demand launches use just
// ['on-demand']; spot launches try spot first and (unless spot-fallback is
// 'fail') fall back to on-demand once spot capacity is exhausted.
function buildMarketPlan(marketType, spotFallback) {
if (marketType !== 'spot') {
return ['on-demand'];
}
return spotFallback === 'fail' ? ['spot'] : ['spot', 'on-demand'];
}

// Run the capacity-fallback chain once per market in the plan. Spot is
// tried across the whole type × subnet matrix first; only when that matrix
// is exhausted by capacity/price errors do we downgrade to the next market
// (on-demand). A fatal error inside a market aborts immediately without
// downgrading. attemptFor(marketType) returns the per-cell attempt fn.
async function launchAcrossMarkets(attemptFor, marketPlan, instanceTypes, subnetIds, hooks = {}) {
for (let i = 0; i < marketPlan.length; i++) {
const marketType = marketPlan[i];
try {
const placement = await launchWithFallback(attemptFor(marketType), instanceTypes, subnetIds);
return { ...placement, marketType };
} catch (error) {
const hasNextMarket = i < marketPlan.length - 1;
if (error.capacityExhausted && hasNextMarket) {
if (hooks.onDowngrade) {
hooks.onDowngrade(marketType, marketPlan[i + 1], error);
}
continue;
}
throw error;
}
}
/* istanbul ignore next — marketPlan is always non-empty */
throw new Error('no market attempt was made');
}

// Instance tag the bootstrap script writes to phone home its progress.
// The start action polls it to fail fast on cloud-init errors instead of
// waiting out the full registration timeout. See buildUserData().
Expand Down Expand Up @@ -105144,8 +105199,14 @@ async function startEc2Instance(label, githubRegistrationToken) {
label,
});

const attempt = async (instanceType, subnetId) => {
// For each market, build the per-cell attempt that injects the
// instance type, subnet, and (for spot) the market options.
const attemptFor = (marketType) => async (instanceType, subnetId) => {
const attemptParams = { ...params, InstanceType: instanceType, SubnetId: subnetId };
const marketOptions = buildMarketOptions(marketType, config.input.spotMaxPrice);
if (marketOptions) {
attemptParams.InstanceMarketOptions = marketOptions;
}
const result = await withRetry(
'run_instance',
() => client.send(new RunInstancesCommand(attemptParams)),
Expand All @@ -105154,18 +105215,24 @@ async function startEc2Instance(label, githubRegistrationToken) {
return result.Instances[0].InstanceId;
};

const marketPlan = buildMarketPlan(config.input.marketType, config.input.spotFallback);
let placement;
const runStart = Date.now();
try {
placement = await launchWithFallback(attempt, instanceTypes, subnetIds);
placement = await launchAcrossMarkets(attemptFor, marketPlan, instanceTypes, subnetIds, {
onDowngrade: (from, to, error) => {
log.warn('spot_fallback', { from, to, reason: error.message });
core.warning(`Spot capacity unavailable — falling back to ${to}`);
},
});
} catch (error) {
log.error('run_instance', { error: error.name, message: error.message });
core.error('AWS EC2 instance starting error');
throw error;
}
const ec2InstanceId = placement.instanceId;
log.info('run_instance', { instance_id: ec2InstanceId, instance_type: placement.instanceType, subnet_id: placement.subnetId, elapsed_ms: Date.now() - runStart });
core.info(`AWS EC2 instance ${ec2InstanceId} is started (type ${placement.instanceType}, subnet ${placement.subnetId})`);
log.info('run_instance', { instance_id: ec2InstanceId, instance_type: placement.instanceType, subnet_id: placement.subnetId, market_type: placement.marketType, elapsed_ms: Date.now() - runStart });
core.info(`AWS EC2 instance ${ec2InstanceId} is started (type ${placement.instanceType}, subnet ${placement.subnetId}, ${placement.marketType})`);

if (config.input.eipAllocationId) {
await waitForInstanceRunning(ec2InstanceId);
Expand All @@ -105182,7 +105249,7 @@ async function startEc2Instance(label, githubRegistrationToken) {
}
}

return { instanceId: ec2InstanceId, instanceType: placement.instanceType, subnetId: placement.subnetId };
return { instanceId: ec2InstanceId, instanceType: placement.instanceType, subnetId: placement.subnetId, marketType: placement.marketType };
}

async function terminateInstanceById(ec2InstanceId) {
Expand Down Expand Up @@ -105361,6 +105428,9 @@ module.exports = {
// Exported for unit testing.
classifyRunError,
launchWithFallback,
launchAcrossMarkets,
buildMarketOptions,
buildMarketPlan,
buildRootDeviceMapping,
wantsRootDeviceMapping,
buildVolumeOpts,
Expand Down Expand Up @@ -105536,6 +105606,9 @@ class Config {
ec2ImageId: core.getInput('ec2-image-id'),
ec2InstanceType: core.getInput('ec2-instance-type'),
subnetId: core.getInput('subnet-id'),
marketType: core.getInput('market-type') || 'on-demand',
spotFallback: core.getInput('spot-fallback') || 'on-demand',
spotMaxPrice: core.getInput('spot-max-price'),
securityGroupId: core.getInput('security-group-id'),
eipAllocationId: core.getInput('eip-allocation-id'),
label: core.getInput('label'),
Expand Down Expand Up @@ -105589,6 +105662,7 @@ class Config {
throw new Error(`Not all the required inputs for AMI search are provided for the 'start' mode`);
}
this.validateVolumeInputs();
this.validateMarketInputs();
} 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 @@ -105631,6 +105705,22 @@ class Config {
}
}

// Validate the spot/market inputs at config parse (fail fast before any
// AWS call). See src/aws.js buildMarketOptions/buildMarketPlan.
validateMarketInputs() {
const { marketType, spotFallback, spotMaxPrice } = this.input;
if (!['on-demand', 'spot'].includes(marketType)) {
throw new Error(`'market-type' must be one of: on-demand, spot`);
}
if (!['on-demand', 'fail'].includes(spotFallback)) {
throw new Error(`'spot-fallback' must be one of: on-demand, fail`);
}
// Positive decimal string, e.g. "0.05" or "1". Empty = AWS default cap.
if (spotMaxPrice && !(/^[0-9]+(\.[0-9]+)?$/.test(spotMaxPrice) && Number(spotMaxPrice) > 0)) {
throw new Error(`'spot-max-price' must be a positive decimal (USD/hour), e.g. 0.05`);
}
}

generateUniqueLabel() {
return Math.random().toString(36).substr(2, 5);
}
Expand Down Expand Up @@ -110925,19 +111015,20 @@ const core = __nccwpck_require__(7484);
// command; GitHub runners now surface that as a warning. Bypass the
// legacy path — modern runners always set GITHUB_OUTPUT.
function setOutput(label, placement) {
const { instanceId, instanceType, subnetId } = placement;
const { instanceId, instanceType, subnetId, marketType } = placement;
const outputFile = process.env.GITHUB_OUTPUT;
if (outputFile) {
fs.appendFileSync(
outputFile,
`label=${label}${os.EOL}ec2-instance-id=${instanceId}${os.EOL}instance-type-used=${instanceType}${os.EOL}subnet-id-used=${subnetId}${os.EOL}`,
`label=${label}${os.EOL}ec2-instance-id=${instanceId}${os.EOL}instance-type-used=${instanceType}${os.EOL}subnet-id-used=${subnetId}${os.EOL}market-type-used=${marketType}${os.EOL}`,
);
return;
}
core.setOutput('label', label);
core.setOutput('ec2-instance-id', instanceId);
core.setOutput('instance-type-used', instanceType);
core.setOutput('subnet-id-used', subnetId);
core.setOutput('market-type-used', marketType);
}

async function start() {
Expand Down
Loading