Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
d6fa056
feat: update action runner to the latest version and install libicu f…
Nov 28, 2024
dd84615
feat: trigger ci on any branch
Nov 28, 2024
6b4f7b9
feat: update self-hosted agent fromm AL2 to AL2023
Nov 28, 2024
948f8c9
feat: update self-hosted agent fromm AL2 to AL2023
Nov 28, 2024
6654a7e
feat: update self-hosted agent fromm AL2 to AL2023
Nov 28, 2024
1c8b278
feat: bump actions/runner to v2.333.1 for node24 support
kurok Apr 20, 2026
6e6cabe
ci: verify dist is in sync with src and verify pinned runner URL
kurok Apr 20, 2026
2689526
ci: bump deprecated actions/checkout and actions/cache to v4 in lint-…
kurok Apr 20, 2026
a6a82df
Merge pull request #3 from namecheap/feat/bump-runner-2.333.1
kurok Apr 20, 2026
9f5ebfe
feat: declare action runtime as node24
kurok Apr 20, 2026
afb0195
Merge pull request #4 from namecheap/feat/action-runs-on-node24
kurok Apr 20, 2026
36d00ed
fix: write outputs to GITHUB_OUTPUT file instead of ::set-output
kurok Apr 20, 2026
945b406
Merge pull request #5 from namecheap/feat/set-output-deprecation
kurok Apr 20, 2026
ae2cb82
fix: silence DEP0169 url.parse deprecation from bundled aws-sdk v2
kurok Apr 20, 2026
54459d6
Merge pull request #6 from namecheap/fix/aws-sdk-url-parse-deprecation
kurok Apr 20, 2026
8b8869f
test: add jest unit tests for utils and config (Phase 8.a) (#16)
kurok Apr 20, 2026
a1bd2f9
feat: migrate aws-sdk v2 to @aws-sdk/client-ec2 v3 (Phase 1) (#17)
kurok Apr 21, 2026
7b949a3
feat: non-root runner user, --ephemeral flag, configurable runner ver…
kurok Apr 21, 2026
78f98d1
fix: revert non-root runner bootstrap, keep the rest of Phase 4 (#19)
kurok Apr 21, 2026
249efbd
revert: full rollback of Phase 4 bootstrap to Phase 1 known-good (#21)
kurok Apr 21, 2026
b1b8d6d
feat: structured logging + opt-in debug mode (Phase 7) (#22)
kurok Apr 21, 2026
46cf1d0
feat: retry + independent cleanup in stop (Phase 5) (#23)
kurok Apr 21, 2026
6bb148b
feat: enforce IMDSv2 by default (Phase 6.a) (#24)
kurok Apr 21, 2026
fd15768
docs: OIDC-preferred + GitHub App token recommendations (Phases 2 + 3…
kurok Apr 21, 2026
0fdd401
feat: Phase 4 (retry) — non-root runner + --ephemeral + hardcoded che…
kurok Apr 21, 2026
7c6a9a7
feat: opt-in EBS encryption for runner root volume (Phase 6.b) (#27)
kurok Apr 21, 2026
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
6 changes: 6 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# ncc-bundled output contains source-embedded CR bytes inside string
# literals (aws-sdk deps, etc.). Treat the whole dist/ tree as binary
# so git's autocrlf doesn't strip them on commit, which otherwise
# produces a permanent mismatch between the committed blob and a
# fresh `npm run package` rebuild.
dist/** -text
112 changes: 108 additions & 4 deletions .github/workflows/pr.yml
Original file line number Diff line number Diff line change
@@ -1,21 +1,125 @@
name: PR automations
on:
pull_request:
branches:
- main
jobs:
lint-code:
name: Lint code
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v2
uses: actions/checkout@v4
- name: Cache dependencies
uses: actions/cache@v2
uses: actions/cache@v4
with:
path: '**/node_modules'
key: ec2-github-runner-${{ hashFiles('**/package-lock.json') }}
- name: Install packages
run: npm install
- name: Run linter
run: npm run lint

verify-dist:
name: Verify dist is up to date
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: 20
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

update to 24 here?

cache: npm
- name: Install packages
run: npm ci
- name: Rebuild dist
run: npm run package
- name: Fail if dist/ differs from committed copy
# ncc 0.38 produces code-split chunks alongside dist/index.js
# (e.g. dist/136.index.js); the whole dist/ tree must stay in
# sync with src/.
run: |
if ! git diff --quiet -- dist/ || [ -n "$(git status --porcelain -- dist/)" ]; then
echo "::error::dist/ is out of sync with src/."
echo "::error::Run 'npm run package' locally and commit the rebuilt dist/."
git status --porcelain -- dist/
git diff --stat -- dist/
exit 1
fi

unit-tests:
name: Unit tests
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- name: Install packages
run: npm ci
- name: Run jest
run: npm test

verify-runner-url:
name: Verify pinned actions/runner release + checksum table
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Extract default runner version from action.yml
id: extract
run: |
# action.yml declares:
# runner-version:
# ...
# default: '2.333.1'
version=$(awk '/^ runner-version:/{found=1} found && /^ default:/{gsub(/[^0-9.]/, "", $2); print $2; exit}' action.yml)
if [ -z "$version" ]; then
echo "::error::Could not locate the default runner-version in action.yml"
exit 1
fi
echo "version=$version" >> "$GITHUB_OUTPUT"
echo "Default actions/runner: v$version"
- name: HEAD check the Linux x64 release asset
env:
VERSION: ${{ steps.extract.outputs.version }}
run: |
url="https://github.com/actions/runner/releases/download/v${VERSION}/actions-runner-linux-x64-${VERSION}.tar.gz"
echo "Checking $url"
curl -fsSLI -o /dev/null "$url"
- name: Cross-check src/runner-checksums.js against release body
env:
VERSION: ${{ steps.extract.outputs.version }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
# Pull the release body once.
body=$(gh api "/repos/actions/runner/releases/tags/v${VERSION}" --jq .body)

# Extract upstream hashes from HTML-comment-wrapped markdown like:
# <!-- BEGIN SHA linux-x64 -->hex<!-- END SHA linux-x64 -->
upstream_x64=$(printf '%s' "$body" | grep -oE 'BEGIN SHA linux-x64 -->[a-f0-9]+' | cut -d'>' -f2)
upstream_arm64=$(printf '%s' "$body" | grep -oE 'BEGIN SHA linux-arm64 -->[a-f0-9]+' | cut -d'>' -f2)

if [ -z "$upstream_x64" ] || [ -z "$upstream_arm64" ]; then
echo "::error::Could not parse linux-x64 / linux-arm64 SHA from release body"
exit 1
fi

# Extract committed hashes from src/runner-checksums.js by loading
# it as a Node module. The module exports { CHECKSUMS, lookup(...) }.
committed_x64=$(node -e "console.log(require('./src/runner-checksums').lookup('x64', process.env.VERSION) || '')")
committed_arm64=$(node -e "console.log(require('./src/runner-checksums').lookup('arm64', process.env.VERSION) || '')")

ok=true
if [ "$upstream_x64" != "$committed_x64" ]; then
echo "::error::runner-checksums.js x64-$VERSION ($committed_x64) != upstream ($upstream_x64)"
ok=false
fi
if [ "$upstream_arm64" != "$committed_arm64" ]; then
echo "::error::runner-checksums.js arm64-$VERSION ($committed_arm64) != upstream ($upstream_arm64)"
ok=false
fi
$ok
echo "Checksums verified for v$VERSION (x64 + arm64)."
99 changes: 93 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,9 +64,65 @@ EC2 self-hosted runner will handle everything else so that you will pay for it t

Use the following steps to prepare your workflow for running on your EC2 self-hosted runner:

**1. Prepare IAM user with AWS access keys**
**1. Configure AWS credentials (OIDC preferred)**

This action reads AWS credentials from the environment. Two paths — pick one.

**Option A (preferred): GitHub OIDC.** No long-lived static keys in your GitHub secrets. A short-lived STS token is minted per workflow run, scoped to the exact repo / branch / environment.

1. Create an OIDC provider for GitHub in your AWS account (one-time per account). The thumbprint is `6938fd4d98bab03faadb97b34396831e3780aea1` as of this writing.
2. Create an IAM role with a trust relationship to `token.actions.githubusercontent.com`:

```hcl
# Terraform
resource "aws_iam_role" "github_runner" {
name = "github-runner"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Principal = { Federated = "arn:aws:iam::<account>:oidc-provider/token.actions.githubusercontent.com" }
Action = "sts:AssumeRoleWithWebIdentity"
Condition = {
StringEquals = {
"token.actions.githubusercontent.com:aud" = "sts.amazonaws.com"
}
StringLike = {
"token.actions.githubusercontent.com:sub" = "repo:<org>/<repo>:*"
}
}
}]
})
}
```

3. Attach the least-privilege permissions policy below to that role.
4. In the workflow, grant OIDC permission to the job and assume the role via `aws-actions/configure-aws-credentials` without any access-key secrets:

```yaml
permissions:
id-token: write # required for OIDC
contents: read
steps:
- uses: aws-actions/configure-aws-credentials@<sha>
with:
role-to-assume: arn:aws:iam::<account>:role/github-runner
aws-region: <region>
- uses: namecheap/ec2-github-runner@<sha>
with:
mode: start
# ...
```

1. Create new AWS access keys for the new or an existing IAM user with the following least-privilege minimum required permissions:
**Option B (legacy): static IAM access keys.** Only use this if OIDC isn't available (e.g., restricted AWS Organization SCPs). The keys rotate manually and live in GitHub secrets indefinitely — a permanent attack surface.

1. Create an IAM user with the same permissions policy below.
2. Generate an access key pair for the user; store as `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` secrets.
3. Use `aws-actions/configure-aws-credentials` with those secrets.

**Permissions policy (both paths)**

1. Attach the following least-privilege minimum required permissions to the role (Option A) or user (Option B):

```
{
Expand Down Expand Up @@ -136,11 +192,42 @@ Use the following steps to prepare your workflow for running on your EC2 self-ho
2. Add the keys to GitHub secrets.
3. Use the [aws-actions/configure-aws-credentials](https://github.com/aws-actions/configure-aws-credentials) action to set up the keys as environment variables.

**2. Prepare GitHub personal access token**
**2. Prepare the GitHub token**

The action's `github-token` input needs permission to manage self-hosted runners on the target repo — specifically it hits `POST /repos/:owner/:repo/actions/runners/registration-token` and `DELETE /repos/:owner/:repo/actions/runners/:id`. Three token types work; pick the lowest-privilege one your setup supports.

**Option A (preferred): GitHub App installation token.** No human identity, no long-lived secret.

1. Create a GitHub App in your org with the permissions below. Grant it installation on the target repo.
2. In the workflow, mint a short-lived installation token via `actions/create-github-app-token@<sha>` and pass its output to this action's `github-token` input.

```yaml
- uses: actions/create-github-app-token@<sha>
id: app-token
with:
app-id: ${{ vars.RUNNER_APP_ID }}
private-key: ${{ secrets.RUNNER_APP_PRIVATE_KEY }}
- uses: namecheap/ec2-github-runner@<sha>
with:
mode: start
github-token: ${{ steps.app-token.outputs.token }}
# ...
```

**Minimum permissions for the App:**
- Repository — **Administration**: Read and write.

**Option B: fine-grained personal access token.** Scoped to specific repos, per-resource permissions. Expires. Better than a classic PAT, worse than an App because it's tied to a human identity.

1. GitHub → Settings → Developer settings → Fine-grained tokens → Generate new.
2. Resource owner: your org. Repositories: only the repos where this action runs.
3. Repository permissions: **Administration: Read and write**. Nothing else.
4. Store as a GitHub secret; pass via `github-token`.

**Option C (deprecated): classic personal access token.** Grants repo-wide permissions far broader than this action needs. Tied to a human identity — CI breaks when the person leaves the org. Only use this if neither of the above is available.

1. Create a new GitHub personal access token with the `repo` scope.
The action will use the token for self-hosted runners management in the GitHub account on the repository level.
2. Add the token to GitHub secrets.
1. Scope: `repo` (necessary evil — finer-grained scopes don't exist on classic PATs).
2. Store as a GitHub secret; pass via `github-token`.

**3. Prepare EC2 image**

Expand Down
44 changes: 43 additions & 1 deletion action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,48 @@ inputs:
IAM Role Name to attach to the created EC2 instance.
This requires additional permissions on the AWS role used to launch instances.
required: false
runner-version:
description: >-
Version of the actions/runner binary to download and register.
Must be one of the versions for which an entry exists in
src/runner-checksums.js (the action verifies the downloaded
tarball's SHA-256 against that table before extraction). To
override, add the corresponding hash to the table in a PR.
required: false
default: '2.333.1'
encrypt-ebs:
description: >-
When 'true', the root EBS volume is created with SSE-EBS
encryption enabled (AWS-managed KMS key, 'alias/aws/ebs', in
the launch account). Requires that the account either has
default EBS encryption enabled or can use the default AWS-
managed KMS key. The AMI's BlockDeviceMapping is cloned and
patched with 'Encrypted: true'; volume size / type / IOPS
are preserved from the AMI. Default 'false' to avoid
regressing consumers whose IAM / KMS policy doesn't allow
this — opt in explicitly when you've verified the permissions.
required: false
default: 'false'
http-tokens:
description: >-
Instance Metadata Service (IMDS) token mode. Accepted values:
- 'required' (default): IMDSv2-only. Any request to the IMDS
endpoint (169.254.169.254) must present a session token.
Mitigates SSRF-style credential theft.
- 'optional': IMDSv1 and IMDSv2 both work. Only set this if
a consumer workflow explicitly needs IMDSv1 compatibility.
Passed through to RunInstances MetadataOptions.HttpTokens.
required: false
default: 'required'
debug:
description: >-
When 'true', the action emits extra diagnostic output to the
Actions run log: input parameters (secrets redacted), AWS SDK
response metadata, runner-registration poll details. Leave at
'false' for normal operation. Set 'true' when troubleshooting
bootstrap failures.
required: false
default: 'false'
aws-resource-tags:
description: >-
Tags to attach to the launched EC2 instance and volume.
Expand All @@ -89,5 +131,5 @@ outputs:
EC2 Instance Id of the created runner.
The id is used to terminate the EC2 instance when the runner is not needed anymore.
runs:
using: node12
using: node24
main: ./dist/index.js
Loading
Loading