Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 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
420d2a0
fix: remove lodash as not needed deps anymore
dm1tr1yvovk May 15, 2026
025d558
docs: update readme for iam role for runner
dm1tr1yvovk May 15, 2026
fd688c6
docs: document yum-based Linux as the only supported OS family
dm1tr1yvovk May 15, 2026
bfb4be5
chore: add CLAUDE.md with project conventions for AI-assisted work
dm1tr1yvovk May 15, 2026
bfacbe9
ci: bump setup-node to 24 in pr workflow to match action runtime
dm1tr1yvovk May 15, 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: 24
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: 24
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)."
35 changes: 35 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# ec2-github-runner — Claude conventions

## Commit style

- **Conventional Commits**: `feat:`, `fix:`, `docs:`, `chore:`, `refactor:`, etc. Lowercase summary, no trailing period.
- **Do not append `Co-Authored-By: Claude …` trailers.** Commits authored by the user via Claude should look like the user's own commits.
- Bundle related changes into one commit; split unrelated changes into separate commits.

## Supported OS scope

This action targets **yum-based Linux only** (Amazon Linux 2023 tested baseline; AL2 / RHEL family in principle). The bootstrap in `src/aws.js` hardcodes `yum`, `useradd`, `sudo`, `bash`, and a tmpfs `/tmp` (`mount -o remount,size=1G /tmp`). Debian, Ubuntu, Alpine, and other non-yum distros are explicitly out of scope.

When reviewing or editing `userData` in `src/aws.js`:

- Do not propose apt/apk fallbacks, package-manager detection, or other cross-distro portability shims.
- Do not flag hardcoded `yum install` lines as a portability concern — that's the documented contract.
- See the `> [!IMPORTANT]` callout at the top of `README.md` for the user-facing version of this policy.

## Build artifact

`dist/index.js` is a committed `@vercel/ncc` bundle of `src/index.js`. Whenever you change anything under `src/` or modify `package.json` dependencies, rebuild before committing:

```
npm run package
```

CI's `verify-dist` job will fail the PR if `dist/` drifts from a clean build.

## Tests

`npm test` runs Jest against everything under `tests/`. All 52 existing tests must keep passing. New behavior in `src/` should land with a matching test in `tests/`.

## Dependencies

The project deliberately runs lean — `lodash` was removed in favor of native JS. Before adding a new runtime dependency, check whether 5–10 lines of native code would do; the bundle ships in `dist/` on every action invocation, so weight matters.
119 changes: 105 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,13 @@ Run the job on it.
Finally, stop it when you finish.
And all this automatically as a part of your GitHub Actions workflow.

> [!IMPORTANT]
> **Supported operating systems: yum-based Linux only.**
>
> The bootstrap script that this action injects as EC2 `user-data` is hardcoded to use `yum`, `useradd`, `sudo`, `bash`, and a `tmpfs` `/tmp`. That means the AMI you pass via `ec2-image-id` **must** be a yum-based distribution — Amazon Linux 2023 (the tested baseline), Amazon Linux 2, or a RHEL-family image (RHEL / CentOS Stream / Rocky / Alma) whose `/tmp` is mounted as tmpfs.
>
> **Debian, Ubuntu, Alpine, and any other non-yum distributions are not supported.** If you launch this action against such an AMI, the EC2 instance will boot but the runner bootstrap will fail silently inside cloud-init, and the action will eventually time out with a registration error. Cross-distro support is not on the roadmap — if you need it, fork and replace the `userData` array in `src/aws.js`.

![GitHub Actions self-hosted EC2 runner](docs/images/github-actions-summary.png)

See [below](#example) the YAML code of the depicted workflow. <br><br>
Expand Down Expand Up @@ -64,9 +71,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>:*"
}
}
}]
})
}
```

1. Create new AWS access keys for the new or an existing IAM user with the following least-privilege minimum required permissions:
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
# ...
```

**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 All @@ -78,7 +141,8 @@ Use the following steps to prepare your workflow for running on your EC2 self-ho
"ec2:RunInstances",
"ec2:TerminateInstances",
"ec2:DescribeInstances",
"ec2:DescribeInstanceStatus"
"ec2:DescribeInstanceStatus",
"ec2:DescribeImages"
],
"Resource": "*"
}
Expand Down Expand Up @@ -136,18 +200,47 @@ 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**

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.
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.

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

1. Create a new EC2 instance based on any Linux distribution you need.
2. Connect to the instance using SSH, install `docker` and `git`, then enable `docker` service.
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.

For Amazon Linux 2, it looks like the following:
```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. 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**

1. Create a new EC2 instance based on a **yum-based Linux distribution** — see the [Supported operating systems](#on-demand-self-hosted-aws-ec2-runner-for-github-actions) notice above. Amazon Linux 2023 is the tested baseline.
2. Connect to the instance using SSH, install `docker` and `git`, then enable `docker` service:

```
sudo yum update -y && \
Expand All @@ -156,8 +249,6 @@ Use the following steps to prepare your workflow for running on your EC2 self-ho
sudo systemctl enable docker
```

For other Linux distributions, it could be slightly different.

3. Install any other tools required for your workflow.
4. Create a new EC2 image (AMI) from the instance.
5. Remove the instance if not required anymore after the image is created.
Expand Down Expand Up @@ -185,7 +276,7 @@ Now you're ready to go!
| ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `mode` | Always required. | Specify here which mode you want to use: <br> - `start` - to start a new runner; <br> - `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. | EC2 Image Id (AMI). <br><br> The new runner will be launched from this image. <br><br> The action is compatible with Amazon Linux 2 images. |
| `ec2-image-id` | Required if you use the `start` mode. | EC2 Image Id (AMI). <br><br> The new runner will be launched from this image. <br><br> Only **yum-based** AMIs are supported (Amazon Linux 2023 tested; AL2 / RHEL-family in principle). See the [Supported operating systems](#on-demand-self-hosted-aws-ec2-runner-for-github-actions) notice at the top of this README. |
| `ec2-instance-type` | Required if you use the `start` mode. | EC2 Instance Type. |
| `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. |
| `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. |
Expand Down
Loading
Loading