cdk-github-runner-switch 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,201 @@
1
+ Apache License
2
+ Version 2.0, January 2004
3
+ http://www.apache.org/licenses/
4
+
5
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6
+
7
+ 1. Definitions.
8
+
9
+ "License" shall mean the terms and conditions for use, reproduction,
10
+ and distribution as defined by Sections 1 through 9 of this document.
11
+
12
+ "Licensor" shall mean the copyright owner or entity authorized by
13
+ the copyright owner that is granting the License.
14
+
15
+ "Legal Entity" shall mean the union of the acting entity and all
16
+ other entities that control, are controlled by, or are under common
17
+ control with that entity. For the purposes of this definition,
18
+ "control" means (i) the power, direct or indirect, to cause the
19
+ direction or management of such entity, whether by contract or
20
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
21
+ outstanding shares, or (iii) beneficial ownership of such entity.
22
+
23
+ "You" (or "Your") shall mean an individual or Legal Entity
24
+ exercising permissions granted by this License.
25
+
26
+ "Source" form shall mean the preferred form for making modifications,
27
+ including but not limited to software source code, documentation
28
+ source, and configuration files.
29
+
30
+ "Object" form shall mean any form resulting from mechanical
31
+ transformation or translation of a Source form, including but
32
+ not limited to compiled object code, generated documentation,
33
+ and conversions to other media types.
34
+
35
+ "Work" shall mean the work of authorship, whether in Source or
36
+ Object form, made available under the License, as indicated by a
37
+ copyright notice that is included in or attached to the work
38
+ (an example is provided in the Appendix below).
39
+
40
+ "Derivative Works" shall mean any work, whether in Source or Object
41
+ form, that is based on (or derived from) the Work and for which the
42
+ editorial revisions, annotations, elaborations, or other modifications
43
+ represent, as a whole, an original work of authorship. For the purposes
44
+ of this License, Derivative Works shall not include works that remain
45
+ separable from, or merely link (or bind by name) to the interfaces of,
46
+ the Work and Derivative Works thereof.
47
+
48
+ "Contribution" shall mean any work of authorship, including
49
+ the original version of the Work and any modifications or additions
50
+ to that Work or Derivative Works thereof, that is intentionally
51
+ submitted to Licensor for inclusion in the Work by the copyright owner
52
+ or by an individual or Legal Entity authorized to submit on behalf of
53
+ the copyright owner. For the purposes of this definition, "submitted"
54
+ means any form of electronic, verbal, or written communication sent
55
+ to the Licensor or its representatives, including but not limited to
56
+ communication on electronic mailing lists, source code control systems,
57
+ and issue tracking systems that are managed by, or on behalf of, the
58
+ Licensor for the purpose of discussing and improving the Work, but
59
+ excluding communication that is conspicuously marked or otherwise
60
+ designated in writing by the copyright owner as "Not a Contribution."
61
+
62
+ "Contributor" shall mean Licensor and any individual or Legal Entity
63
+ on behalf of whom a Contribution has been received by Licensor and
64
+ subsequently incorporated within the Work.
65
+
66
+ 2. Grant of Copyright License. Subject to the terms and conditions of
67
+ this License, each Contributor hereby grants to You a perpetual,
68
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69
+ copyright license to reproduce, prepare Derivative Works of,
70
+ publicly display, publicly perform, sublicense, and distribute the
71
+ Work and such Derivative Works in Source or Object form.
72
+
73
+ 3. Grant of Patent License. Subject to the terms and conditions of
74
+ this License, each Contributor hereby grants to You a perpetual,
75
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76
+ (except as stated in this section) patent license to make, have made,
77
+ use, offer to sell, sell, import, and otherwise transfer the Work,
78
+ where such license applies only to those patent claims licensable
79
+ by such Contributor that are necessarily infringed by their
80
+ Contribution(s) alone or by combination of their Contribution(s)
81
+ with the Work to which such Contribution(s) was submitted. If You
82
+ institute patent litigation against any entity (including a
83
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
84
+ or a Contribution incorporated within the Work constitutes direct
85
+ or contributory patent infringement, then any patent licenses
86
+ granted to You under this License for that Work shall terminate
87
+ as of the date such litigation is filed.
88
+
89
+ 4. Redistribution. You may reproduce and distribute copies of the
90
+ Work or Derivative Works thereof in any medium, with or without
91
+ modifications, and in Source or Object form, provided that You
92
+ meet the following conditions:
93
+
94
+ (a) You must give any other recipients of the Work or
95
+ Derivative Works a copy of this License; and
96
+
97
+ (b) You must cause any modified files to carry prominent notices
98
+ stating that You changed the files; and
99
+
100
+ (c) You must retain, in the Source form of any Derivative Works
101
+ that You distribute, all copyright, patent, trademark, and
102
+ attribution notices from the Source form of the Work,
103
+ excluding those notices that do not pertain to any part of
104
+ the Derivative Works; and
105
+
106
+ (d) If the Work includes a "NOTICE" text file as part of its
107
+ distribution, then any Derivative Works that You distribute must
108
+ include a readable copy of the attribution notices contained
109
+ within such NOTICE file, excluding those notices that do not
110
+ pertain to any part of the Derivative Works, in at least one
111
+ of the following places: within a NOTICE text file distributed
112
+ as part of the Derivative Works; within the Source form or
113
+ documentation, if provided along with the Derivative Works; or,
114
+ within a display generated by the Derivative Works, if and
115
+ wherever such third-party notices normally appear. The contents
116
+ of the NOTICE file are for informational purposes only and
117
+ do not modify the License. You may add Your own attribution
118
+ notices within Derivative Works that You distribute, alongside
119
+ or as an addendum to the NOTICE text from the Work, provided
120
+ that such additional attribution notices cannot be construed
121
+ as modifying the License.
122
+
123
+ You may add Your own copyright statement to Your modifications and
124
+ may provide additional or different license terms and conditions
125
+ for use, reproduction, or distribution of Your modifications, or
126
+ for any such Derivative Works as a whole, provided Your use,
127
+ reproduction, and distribution of the Work otherwise complies with
128
+ the conditions stated in this License.
129
+
130
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
131
+ any Contribution intentionally submitted for inclusion in the Work
132
+ by You to the Licensor shall be under the terms and conditions of
133
+ this License, without any additional terms or conditions.
134
+ Notwithstanding the above, nothing herein shall supersede or modify
135
+ the terms of any separate license agreement you may have executed
136
+ with Licensor regarding such Contributions.
137
+
138
+ 6. Trademarks. This License does not grant permission to use the trade
139
+ names, trademarks, service marks, or product names of the Licensor,
140
+ except as required for reasonable and customary use in describing the
141
+ origin of the Work and reproducing the content of the NOTICE file.
142
+
143
+ 7. Disclaimer of Warranty. Unless required by applicable law or
144
+ agreed to in writing, Licensor provides the Work (and each
145
+ Contributor provides its Contributions) on an "AS IS" BASIS,
146
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147
+ implied, including, without limitation, any warranties or conditions
148
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149
+ PARTICULAR PURPOSE. You are solely responsible for determining the
150
+ appropriateness of using or redistributing the Work and assume any
151
+ risks associated with Your exercise of permissions under this License.
152
+
153
+ 8. Limitation of Liability. In no event and under no legal theory,
154
+ whether in tort (including negligence), contract, or otherwise,
155
+ unless required by applicable law (such as deliberate and grossly
156
+ negligent acts) or agreed to in writing, shall any Contributor be
157
+ liable to You for damages, including any direct, indirect, special,
158
+ incidental, or consequential damages of any character arising as a
159
+ result of this License or out of the use or inability to use the
160
+ Work (including but not limited to damages for loss of goodwill,
161
+ work stoppage, computer failure or malfunction, or any and all
162
+ other commercial damages or losses), even if such Contributor
163
+ has been advised of the possibility of such damages.
164
+
165
+ 9. Accepting Warranty or Additional Liability. While redistributing
166
+ the Work or Derivative Works thereof, You may choose to offer,
167
+ and charge a fee for, acceptance of support, warranty, indemnity,
168
+ or other liability obligations and/or rights consistent with this
169
+ License. However, in accepting such obligations, You may act only
170
+ on Your own behalf and on Your sole responsibility, not on behalf
171
+ of any other Contributor, and only if You agree to indemnify,
172
+ defend, and hold each Contributor harmless for any liability
173
+ incurred by, or claims asserted against, such Contributor by reason
174
+ of your accepting any such warranty or additional liability.
175
+
176
+ END OF TERMS AND CONDITIONS
177
+
178
+ APPENDIX: How to apply the Apache License to your work.
179
+
180
+ To apply the Apache License to your work, attach the following
181
+ boilerplate notice, with the fields enclosed by brackets "[]"
182
+ replaced with your own identifying information. (Don't include
183
+ the brackets!) The text should be enclosed in the appropriate
184
+ comment syntax for the file format. We also recommend that a
185
+ file or class name and description of purpose be included on the
186
+ same "printed page" as the copyright notice for easier
187
+ identification within third-party archives.
188
+
189
+ Copyright [yyyy] [name of copyright owner]
190
+
191
+ Licensed under the Apache License, Version 2.0 (the "License");
192
+ you may not use this file except in compliance with the License.
193
+ You may obtain a copy of the License at
194
+
195
+ http://www.apache.org/licenses/LICENSE-2.0
196
+
197
+ Unless required by applicable law or agreed to in writing, software
198
+ distributed under the License is distributed on an "AS IS" BASIS,
199
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
200
+ See the License for the specific language governing permissions and
201
+ limitations under the License.
package/NOTICE ADDED
@@ -0,0 +1,4 @@
1
+ cdk-github-runner-switch
2
+ Copyright 2026 uarch-solutions
3
+
4
+ This product is licensed under the Apache License, Version 2.0 (see LICENSE).
package/README.md CHANGED
@@ -1,3 +1,145 @@
1
- # Temporary Holding Version
1
+ # cdk-github-runner-switch
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ [Leia em português](README.pt-BR.md)
4
+
5
+ An AWS CDK construct for teams on GitHub's free Actions minutes. It keeps your workflows on **GitHub-hosted runners
6
+ while the free minutes last**. When the monthly minutes run out, it **switches automatically to disposable EC2 Spot
7
+ machines (Graviton, arm64) in your AWS account**. On the 1st of the next month it switches back.
8
+
9
+ There is nothing to remember and no blocked pipeline at the end of the month. You don't pay for idle machines.
10
+
11
+ ```mermaid
12
+ flowchart LR
13
+ subgraph GitHub
14
+ W[Workflow job<br/>runs-on uses vars.RUNNER] -->|webhook workflow_job| A[GitHub App]
15
+ V[(Repository variable<br/>RUNNER = github / aws)]
16
+ end
17
+ subgraph AWS
18
+ F[Lambda<br/>Function URL] -->|RunInstances Spot → on-demand| E[EC2 arm64<br/>1 job, then terminates]
19
+ S[EventBridge<br/>every 30 min] -->|check-usage| F
20
+ R[EventBridge<br/>every 5 min] -->|reconcile| F
21
+ I[Image Builder<br/>monthly Ubuntu 24.04 AMI] --> P[(SSM parameter)]
22
+ end
23
+ A --> F
24
+ F -->|minutes used ≥ threshold| V
25
+ E -->|JIT runner| W
26
+ P --> F
27
+ ```
28
+
29
+ ## Why
30
+
31
+ - **The free plan is enough most of the month.** Paying for runners all the time is a waste, and so is a blocked
32
+ pipeline on the 25th.
33
+ - **Self-hosted runners usually stay on all the time, or need a lot of moving parts.** Here each job gets a fresh
34
+ machine with a single-use runner registration ([JIT](https://docs.github.com/en/actions/hosting-your-own-runners/managing-self-hosted-runners/autoscaling-with-self-hosted-runners#using-just-in-time-runners)).
35
+ The machine terminates when the job ends.
36
+ - **The switch is a repository variable.** Workflows choose the runner with one expression. There is no
37
+ fork of your workflows and no manual step.
38
+
39
+ ## What you get
40
+
41
+ | Piece | What it does |
42
+ |---|---|
43
+ | Webhook (Lambda Function URL) | Checks the GitHub signature. Each queued job that asks for the AWS label gets one machine: Spot on every instance type × subnet first, then on-demand. |
44
+ | Switch (every 30 min) | Reads the Actions minutes used this month and flips `RUNNER` to `aws` at the threshold (default 1,900 of 2,000). It flips back to `github` when the month resets. When it moves to AWS, it re-runs the runs that failed because the minutes ran out. |
45
+ | Reconciliation (every 5 min) | Queued jobs without a machine get one. This covers a lost webhook and jobs that hit the machine cap. |
46
+ | On-demand label | Jobs labeled `aws-ondemand` (for example, deploys) skip Spot, so an interruption never cuts a deploy in half. |
47
+ | Runner image | Ubuntu 24.04 arm64 from Canonical with Docker, Node.js, Python, git, GitHub CLI, AWS CLI and the runner. EC2 Image Builder rebuilds it monthly. You can add cached container images and extra commands. |
48
+ | Safety | Security group with no inbound rules, IMDSv2, encrypted disk, shutdown → terminate, a 2-hour guard against hung jobs. The function may only launch, tag and list instances. |
49
+
50
+ ## Quick start
51
+
52
+ ```bash
53
+ npm install cdk-github-runner-switch
54
+ ```
55
+
56
+ ```ts
57
+ import { GitHubRunnerSwitch } from 'cdk-github-runner-switch';
58
+
59
+ const vpc = new ec2.Vpc(stack, 'Vpc', { natGateways: 0, subnetConfiguration: [{ name: 'public', subnetType: ec2.SubnetType.PUBLIC }] });
60
+ const runners = new GitHubRunnerSwitch(stack, 'Runners', { vpc, repository: 'my-org/my-repo' });
61
+ new CfnOutput(stack, 'WebhookUrl', { value: runners.webhookUrl });
62
+ new CfnOutput(stack, 'SecretArn', { value: runners.secret.secretArn });
63
+ new CfnOutput(stack, 'ImagePipelineArn', { value: runners.imagePipelineArn });
64
+ ```
65
+
66
+ Then, once per account and repository:
67
+
68
+ 1. **Spot service-linked role** (only if the account has never used Spot):
69
+ `aws iam create-service-linked-role --aws-service-name spot.amazonaws.com`
70
+ 2. **Deploy** the stack (`cdk deploy`).
71
+ 3. **Build the first image** (about 30 minutes). After that, it is rebuilt on schedule:
72
+ `aws imagebuilder start-image-pipeline-execution --image-pipeline-arn <ImagePipelineArn>`
73
+ 4. **Create the GitHub App and store its key.** Run [`scripts/setup-github-app.sh <stack-name>`](scripts/setup-github-app.sh)
74
+ in your terminal. It prints the webhook URL, the generated webhook secret and the permissions to grant. Then it
75
+ asks for the App ID and the `.pem` file. The key goes straight to Secrets Manager.
76
+ 5. **Point the workflows at the switch:**
77
+
78
+ ```yaml
79
+ runs-on: ${{ vars.RUNNER == 'aws' && fromJSON('["self-hosted","linux","arm64","aws"]') || 'ubuntu-24.04-arm' }}
80
+ ```
81
+
82
+ See [`examples/workflow.yml`](examples/workflow.yml) (with an on-demand deploy job) and
83
+ [`examples/canary.yml`](examples/canary.yml), a weekly liveness test that uses no GitHub minutes.
84
+
85
+ To flip the switch by hand: `gh variable set RUNNER --body aws` (or `github`).
86
+
87
+ ## Configuration
88
+
89
+ | Property | Default | Notes |
90
+ |---|---|---|
91
+ | `vpc` | (required) | Machines need outbound internet access. |
92
+ | `subnets` | public subnets | Public subnets avoid paying for a NAT gateway. The machines still have no inbound port. |
93
+ | `repository` | (required) | `owner/name`. |
94
+ | `billing` | `ORGANIZATION` | `USER` for personal accounts. That needs a fine-grained token with *Plan: read*, which the setup script stores as `billingToken`. |
95
+ | `label` / `onDemandLabel` | `aws` / `aws-ondemand` | Labels used in `runs-on`. |
96
+ | `instanceTypes` | `m7g.large`, `m6g.large`, `m8g.large` | Graviton (arm64), in order of preference. |
97
+ | `thresholdMinutes` | `1900` | When to move to AWS. Set it a little below your plan's quota. |
98
+ | `maxInstances` | `6` | Machine cap. Jobs above it wait and are launched by the reconciliation. |
99
+ | `switchVariable` | `RUNNER` | Repository variable that the construct flips. |
100
+ | `maxJobDuration` | 2 hours | The machine shuts down after this, even if a job hangs. |
101
+ | `rootVolumeSize` | `40` GiB | |
102
+ | `nodeMajorVersion` | `24` | Node.js installed in the image. |
103
+ | `cachedImages` | `[]` | Container images pulled into the AMI, for faster `services:`. |
104
+ | `extraImageCommands` | `[]` | Extra setup, e.g. `npx -y playwright install-deps chromium`. |
105
+ | `imageSchedule` | monthly (day 1, 05:00 UTC) | EventBridge cron for the image rebuild. |
106
+ | `reconcileInterval` / `usageCheckInterval` | 5 min / 30 min | |
107
+
108
+ ## Cost
109
+
110
+ You pay only while jobs run:
111
+
112
+ - the EC2 Spot price of the instance;
113
+ - a few cents of Lambda, EventBridge and Secrets Manager;
114
+ - the storage of one AMI snapshot;
115
+ - one monthly image build (about 30 minutes of a `large` instance).
116
+
117
+ There is no NAT gateway, no load balancer and no machine waiting for work. Check the Spot prices in your region; for
118
+ `m7g.large` they are usually a fraction of the on-demand price.
119
+
120
+ ## Security notes
121
+
122
+ - The webhook is a public Function URL. Every request is authenticated with the App's HMAC signature
123
+ (`X-Hub-Signature-256`). Requests without a valid signature get 401.
124
+ - The GitHub App only needs: repository *Administration* (to create JIT runners), *Actions* (read runs and re-run
125
+ failed ones), *Variables* (the switch), and organization *Administration: read* (billing usage).
126
+ - The machine role only has Session Manager access, for debugging. Give your jobs their own credentials, for
127
+ example with GitHub OIDC.
128
+ - One machine per job, never reused. A job cannot see another job's workspace.
129
+
130
+ ## Limitations
131
+
132
+ - arm64 (Graviton) only, for now. Your workflows need arm64-compatible actions and images.
133
+ - One repository per construct. Instantiate it again for other repositories.
134
+ - Linux only.
135
+
136
+ ## Prior art
137
+
138
+ [github-aws-runners/terraform-aws-github-runner](https://github.com/github-aws-runners/terraform-aws-github-runner) (formerly philips-labs) and
139
+ [CloudSnorkel/cdk-github-runners](https://github.com/CloudSnorkel/cdk-github-runners) are mature, feature-rich
140
+ projects for running GitHub runners on AWS. This construct does something narrower: it **uses GitHub's free minutes
141
+ first and only spills over to AWS when they run out**, with as few moving parts as possible.
142
+
143
+ ## License
144
+
145
+ [Apache-2.0](LICENSE)
@@ -0,0 +1,62 @@
1
+ # cdk-github-runner-switch
2
+
3
+ [Read in English](README.md)
4
+
5
+ Construct do AWS CDK para quem usa os minutos gratuitos do GitHub Actions. A pipeline roda **nas máquinas do GitHub
6
+ enquanto os minutos gratuitos durarem**. Quando eles acabam, ela **passa sozinha para máquinas EC2 Spot descartáveis
7
+ (Graviton, arm64) na sua conta AWS**. No dia 1º do mês seguinte, volta para o GitHub.
8
+
9
+ Ninguém precisa lembrar de nada, a pipeline não trava no fim do mês, e você não paga por máquina parada.
10
+
11
+ ## Por que
12
+
13
+ - **O plano gratuito dá conta da maior parte do mês.** Pagar runner o tempo todo é desperdício, e ver a pipeline
14
+ travar no dia 25 também.
15
+ - **Runner próprio costuma ficar ligado direto, ou exige muita peça.** Aqui cada job ganha uma máquina nova com um
16
+ registro de runner de uso único (JIT), e ela é encerrada quando o job termina.
17
+ - **A chave é uma variável do repositório.** O workflow escolhe o runner com uma expressão, sem duplicar workflows
18
+ nem passo manual.
19
+
20
+ ## Como funciona
21
+
22
+ | Peça | O que faz |
23
+ |---|---|
24
+ | Webhook (Function URL da Lambda) | Confere a assinatura do GitHub. Cada job na fila que pede o rótulo da AWS ganha uma máquina: primeiro Spot, em cada tipo de máquina × sub-rede, depois sob demanda. |
25
+ | Chave (a cada 30 min) | Lê os minutos do Actions usados no mês e vira `RUNNER` para `aws` no limite (padrão: 1.900 de 2.000). Volta para `github` quando o mês vira. Ao ir para a AWS, refaz as execuções que falharam por falta de minutos. |
26
+ | Reconciliação (a cada 5 min) | Job na fila sem máquina ganha uma. Cobre aviso do GitHub que se perdeu e o teto de máquinas. |
27
+ | Rótulo sob demanda | Jobs com `aws-ondemand` (ex.: deploy) não usam Spot, então uma interrupção nunca corta um deploy no meio. |
28
+ | Imagem | Ubuntu 24.04 arm64 da Canonical com Docker, Node.js, Python, git, GitHub CLI, AWS CLI e o runner. O EC2 Image Builder refaz a imagem todo mês. Dá para incluir imagens de contêiner em cache e comandos extras. |
29
+ | Segurança | Sem porta de entrada, IMDSv2, disco criptografado, desligar = encerrar, limite de 2 horas para job travado. A função só lança, marca e lista máquinas. |
30
+
31
+ ## Começando
32
+
33
+ ```bash
34
+ npm install cdk-github-runner-switch
35
+ ```
36
+
37
+ ```ts
38
+ const runners = new GitHubRunnerSwitch(stack, 'Runners', { vpc, repository: 'minha-org/meu-repo' });
39
+ ```
40
+
41
+ Depois, uma vez por conta e repositório:
42
+
43
+ 1. **Papel do Spot** (só se a conta nunca usou Spot):
44
+ `aws iam create-service-linked-role --aws-service-name spot.amazonaws.com`
45
+ 2. **Publique** a stack (`cdk deploy`).
46
+ 3. **Gere a primeira imagem** (cerca de 30 min). Depois disso, ela é refeita sozinha:
47
+ `aws imagebuilder start-image-pipeline-execution --image-pipeline-arn <ImagePipelineArn>`
48
+ 4. **Crie o GitHub App e guarde a chave** com [`scripts/setup-github-app.sh <stack>`](scripts/setup-github-app.sh),
49
+ no seu terminal. A chave vai direto para o Secrets Manager.
50
+ 5. **Aponte os workflows para a chave:**
51
+
52
+ ```yaml
53
+ runs-on: ${{ vars.RUNNER == 'aws' && fromJSON('["self-hosted","linux","arm64","aws"]') || 'ubuntu-24.04-arm' }}
54
+ ```
55
+
56
+ Para trocar à mão: `gh variable set RUNNER --body aws` (ou `github`).
57
+
58
+ As opções, os custos, a segurança e as limitações estão no [README em inglês](README.md#configuration).
59
+
60
+ ## Licença
61
+
62
+ [Apache-2.0](LICENSE)
@@ -0,0 +1,310 @@
1
+ // cdk-github-runner-switch — control function.
2
+ //
3
+ // 1. Webhook `workflow_job` from the GitHub App → one disposable EC2 machine (Spot first) with a single-use (JIT)
4
+ // runner registration for the queued job.
5
+ // 2. Every few minutes (`reconcile`) → queued jobs that have no machine (lost webhook, cap reached) get one.
6
+ // 3. Every half hour (`check-usage`) → flips the repository variable (default `RUNNER`) between `github` and `aws`
7
+ // according to the GitHub Actions minutes used this month, and re-runs the runs that failed for lack of minutes.
8
+ //
9
+ // No dependencies: the AWS SDK comes with the Lambda runtime; `fetch` and `crypto` come with Node.
10
+ import crypto from 'node:crypto';
11
+
12
+ const sdk = {
13
+ ec2: () => import('@aws-sdk/client-ec2'),
14
+ secrets: () => import('@aws-sdk/client-secrets-manager'),
15
+ ssm: () => import('@aws-sdk/client-ssm'),
16
+ };
17
+ const GH = 'https://api.github.com';
18
+ const CAPACITY = /InsufficientInstanceCapacity|SpotMaxPriceTooLow|MaxSpotInstanceCountExceeded|InstanceLimitExceeded|Unsupported/;
19
+
20
+ /** Settings from the environment (set by the construct). Pure functions take them as a parameter for testing. */
21
+ export function config(env = process.env) {
22
+ const label = env.LABEL || 'aws';
23
+ return {
24
+ label,
25
+ onDemandLabel: env.ON_DEMAND_LABEL || `${label}-ondemand`,
26
+ instanceTypes: (env.INSTANCE_TYPES || 'm7g.large,m6g.large,m8g.large').split(',').map((s) => s.trim()).filter(Boolean),
27
+ thresholdMinutes: Number(env.THRESHOLD_MINUTES || 1900),
28
+ maxInstances: Number(env.MAX_INSTANCES || 6),
29
+ switchVariable: env.SWITCH_VARIABLE || 'RUNNER',
30
+ billing: env.BILLING === 'user' ? 'user' : 'organization',
31
+ runnerGroupId: Number(env.RUNNER_GROUP_ID || 1),
32
+ maxJobMinutes: Number(env.MAX_JOB_MINUTES || 120),
33
+ switchId: env.SWITCH_ID || 'default',
34
+ repository: env.REPOSITORY || '',
35
+ };
36
+ }
37
+
38
+ // ---------------------------------------------------------------- pure rules (unit tested)
39
+
40
+ /** `X-Hub-Signature-256` header of the webhook (HMAC-SHA256 of the raw body with the shared secret). */
41
+ export function validSignature(secret, body, header) {
42
+ if (!secret || !header || !header.startsWith('sha256=')) return false;
43
+ const expected = Buffer.from(`sha256=${crypto.createHmac('sha256', secret).update(body).digest('hex')}`);
44
+ const got = Buffer.from(header);
45
+ return got.length === expected.length && crypto.timingSafeEqual(got, expected);
46
+ }
47
+
48
+ /** A queued job asking for one of our runners? Returns `{ onDemand }` or null. */
49
+ export function runnerRequest(payload, c = config()) {
50
+ if (payload?.action !== 'queued') return null;
51
+ const labels = payload.workflow_job?.labels ?? [];
52
+ if (!labels.includes('self-hosted')) return null;
53
+ if (labels.includes(c.onDemandLabel)) return { onDemand: true };
54
+ if (labels.includes(c.label)) return { onDemand: false };
55
+ return null;
56
+ }
57
+
58
+ /** New position of the switch, or null to keep it: `aws` at the threshold; `github` when the month resets. */
59
+ export function nextRunner(usedMinutes, current, c = config()) {
60
+ if (usedMinutes >= c.thresholdMinutes && current !== 'aws') return 'aws';
61
+ if (usedMinutes < c.thresholdMinutes && current === 'aws') return 'github';
62
+ return null;
63
+ }
64
+
65
+ /** GitHub Actions minutes used in the current month (billing usage report; storage is not counted). */
66
+ export function minutesThisMonth(usage, now = new Date()) {
67
+ const month = now.toISOString().slice(0, 7);
68
+ return (usage?.usageItems ?? [])
69
+ .filter((i) => i.product === 'actions' && i.unitType === 'Minutes' && String(i.date).startsWith(month))
70
+ .reduce((t, i) => t + Number(i.quantity || 0), 0);
71
+ }
72
+
73
+ /** Launch attempts in order: Spot on every type × subnet, then on-demand. On-demand jobs skip Spot. */
74
+ export function launchPlan(onDemand, subnets, c = config()) {
75
+ const plan = [];
76
+ if (!onDemand) for (const type of c.instanceTypes) for (const subnet of subnets) plan.push({ type, subnet, spot: true });
77
+ for (const type of c.instanceTypes) for (const subnet of subnets) plan.push({ type, subnet, spot: false });
78
+ return plan;
79
+ }
80
+
81
+ /**
82
+ * Reconciliation: machines missing per label. Each machine carries one runner that is booting, idle or busy;
83
+ * idle = active − busy. Never exceeds the total cap.
84
+ */
85
+ export function missingRunners({ queued, active, busy }, c = config()) {
86
+ let room = c.maxInstances - Object.values(active).reduce((t, n) => t + n, 0);
87
+ const out = {};
88
+ for (const label of [c.onDemandLabel, c.label]) {
89
+ const idle = Math.max(0, (active[label] ?? 0) - (busy[label] ?? 0));
90
+ const need = Math.max(0, Math.min((queued[label] ?? 0) - idle, room));
91
+ if (need > 0) { out[label] = need; room -= need; }
92
+ }
93
+ return out;
94
+ }
95
+
96
+ /** Boot script: runs exactly one job (JIT) and shuts down — the launch template turns shutdown into terminate. */
97
+ export function userData(jitConfig, c = config()) {
98
+ if (!/^[A-Za-z0-9+/=]+$/.test(jitConfig)) throw new Error('invalid JIT config');
99
+ return Buffer.from(`#!/bin/bash
100
+ shutdown -h +${c.maxJobMinutes}
101
+ cd /opt/actions-runner
102
+ sudo -u runner -H ./run.sh --jitconfig '${jitConfig}' > /var/log/actions-runner.log 2>&1 || true
103
+ shutdown -h now
104
+ `).toString('base64');
105
+ }
106
+
107
+ /** Billing usage endpoint: organization report, or the user report for personal accounts. */
108
+ export function usagePath(repository, billing) {
109
+ const owner = repository.split('/')[0];
110
+ return billing === 'user' ? `/users/${owner}/settings/billing/usage` : `/orgs/${owner}/settings/billing/usage`;
111
+ }
112
+
113
+ // ---------------------------------------------------------------- GitHub App
114
+
115
+ let secretCache;
116
+ async function secret() {
117
+ if (secretCache && Date.now() - secretCache.at < 300_000) return secretCache.value;
118
+ const { SecretsManagerClient, GetSecretValueCommand } = await sdk.secrets();
119
+ const r = await new SecretsManagerClient({}).send(new GetSecretValueCommand({ SecretId: process.env.SECRET_ID }));
120
+ secretCache = { at: Date.now(), value: JSON.parse(r.SecretString) };
121
+ return secretCache.value;
122
+ }
123
+
124
+ const b64url = (b) => Buffer.from(b).toString('base64url');
125
+ function appJwt(appId, privateKey) {
126
+ const now = Math.floor(Date.now() / 1000);
127
+ const head = b64url(JSON.stringify({ alg: 'RS256', typ: 'JWT' }));
128
+ const body = b64url(JSON.stringify({ iat: now - 60, exp: now + 540, iss: String(appId) }));
129
+ const sig = crypto.createSign('RSA-SHA256').update(`${head}.${body}`).sign(privateKey, 'base64url');
130
+ return `${head}.${body}.${sig}`;
131
+ }
132
+
133
+ async function gh(path, { token, method = 'GET', body } = {}) {
134
+ const res = await fetch(`${GH}${path}`, {
135
+ method,
136
+ headers: { authorization: `Bearer ${token}`, accept: 'application/vnd.github+json', 'x-github-api-version': '2022-11-28', 'user-agent': 'cdk-github-runner-switch' },
137
+ body: body ? JSON.stringify(body) : undefined,
138
+ });
139
+ const text = await res.text();
140
+ if (!res.ok && res.status !== 404) throw new Error(`GitHub ${method} ${path} → ${res.status} ${text.slice(0, 200)}`);
141
+ return { status: res.status, json: text ? JSON.parse(text) : null };
142
+ }
143
+
144
+ async function installationToken(c) {
145
+ const s = await secret();
146
+ if (!s.appId || !s.privateKey) throw new Error('GitHub App not configured in the secret (appId, privateKey)');
147
+ const jwt = appJwt(s.appId, s.privateKey);
148
+ const inst = await gh(`/repos/${c.repository}/installation`, { token: jwt });
149
+ if (inst.status === 404) throw new Error(`GitHub App is not installed on ${c.repository}`);
150
+ const t = await gh(`/app/installations/${inst.json.id}/access_tokens`, { token: jwt, method: 'POST' });
151
+ return t.json.token;
152
+ }
153
+
154
+ // ---------------------------------------------------------------- AWS
155
+
156
+ let ec2Client;
157
+ async function ec2() {
158
+ const m = await sdk.ec2();
159
+ ec2Client ??= new m.EC2Client({});
160
+ return { client: ec2Client, ...m };
161
+ }
162
+
163
+ async function activeByLabel(c) {
164
+ const { client, DescribeInstancesCommand } = await ec2();
165
+ const r = await client.send(new DescribeInstancesCommand({
166
+ Filters: [{ Name: 'tag:runner-switch', Values: [c.switchId] }, { Name: 'instance-state-name', Values: ['pending', 'running'] }],
167
+ }));
168
+ const out = {};
169
+ for (const i of (r.Reservations ?? []).flatMap((x) => x.Instances ?? [])) {
170
+ const label = i.Tags?.find((t) => t.Key === 'runner-label')?.Value ?? c.label;
171
+ out[label] = (out[label] ?? 0) + 1;
172
+ }
173
+ return out;
174
+ }
175
+
176
+ async function launch(c, jobId, onDemand, jit) {
177
+ const { SSMClient, GetParameterCommand } = await sdk.ssm();
178
+ const ami = (await new SSMClient({}).send(new GetParameterCommand({ Name: process.env.AMI_PARAMETER }))).Parameter.Value;
179
+ const { client, RunInstancesCommand } = await ec2();
180
+ const label = onDemand ? c.onDemandLabel : c.label;
181
+ let last;
182
+ for (const p of launchPlan(onDemand, process.env.SUBNET_IDS.split(','), c)) {
183
+ try {
184
+ const r = await client.send(new RunInstancesCommand({
185
+ MinCount: 1, MaxCount: 1, ImageId: ami, InstanceType: p.type,
186
+ LaunchTemplate: { LaunchTemplateId: process.env.LAUNCH_TEMPLATE_ID, Version: '$Latest' },
187
+ NetworkInterfaces: [{ DeviceIndex: 0, SubnetId: p.subnet, Groups: [process.env.SECURITY_GROUP_ID], AssociatePublicIpAddress: true, DeleteOnTermination: true }],
188
+ UserData: userData(jit, c),
189
+ ...(p.spot ? { InstanceMarketOptions: { MarketType: 'spot', SpotOptions: { SpotInstanceType: 'one-time', InstanceInterruptionBehavior: 'terminate' } } } : {}),
190
+ TagSpecifications: [{
191
+ ResourceType: 'instance',
192
+ Tags: [
193
+ { Key: 'Name', Value: `github-runner-${c.switchId}` }, { Key: 'runner-switch', Value: c.switchId }, { Key: 'github-job', Value: String(jobId) },
194
+ { Key: 'market', Value: p.spot ? 'spot' : 'on-demand' }, { Key: 'runner-label', Value: label },
195
+ ],
196
+ }],
197
+ }));
198
+ const id = r.Instances?.[0]?.InstanceId;
199
+ console.log(JSON.stringify({ launched: id, job: jobId, ...p }));
200
+ return id;
201
+ } catch (e) {
202
+ last = e;
203
+ if (!CAPACITY.test(`${e.name} ${e.message}`)) throw e;
204
+ console.log(JSON.stringify({ unavailable: p, reason: e.name }));
205
+ }
206
+ }
207
+ throw last ?? new Error('no capacity');
208
+ }
209
+
210
+ async function launchFor(c, token, jobId, onDemand) {
211
+ const jit = await gh(`/repos/${c.repository}/actions/runners/generate-jitconfig`, {
212
+ token, method: 'POST',
213
+ body: {
214
+ name: `aws-${jobId}-${Date.now().toString(36)}`, runner_group_id: c.runnerGroupId, work_folder: '_work',
215
+ labels: ['self-hosted', 'linux', 'arm64', onDemand ? c.onDemandLabel : c.label],
216
+ },
217
+ });
218
+ return launch(c, jobId, onDemand, jit.json.encoded_jit_config);
219
+ }
220
+
221
+ // ---------------------------------------------------------------- tasks
222
+
223
+ async function onWebhook(event, c) {
224
+ const raw = event.isBase64Encoded ? Buffer.from(event.body ?? '', 'base64').toString('utf8') : event.body ?? '';
225
+ const headers = Object.fromEntries(Object.entries(event.headers ?? {}).map(([k, v]) => [k.toLowerCase(), v]));
226
+ if (!validSignature((await secret()).webhookSecret, raw, headers['x-hub-signature-256'])) return { statusCode: 401, body: 'invalid signature' };
227
+ if (headers['x-github-event'] !== 'workflow_job') return { statusCode: 204 };
228
+ const payload = JSON.parse(raw);
229
+ const req = runnerRequest(payload, c);
230
+ if (!req) return { statusCode: 204 };
231
+ const active = Object.values(await activeByLabel(c)).reduce((t, n) => t + n, 0);
232
+ if (active >= c.maxInstances) {
233
+ // The reconciliation launches it as soon as there is room.
234
+ console.log(JSON.stringify({ capped: payload.workflow_job.id }));
235
+ return { statusCode: 202, body: 'cap reached; will be reconciled' };
236
+ }
237
+ const id = await launchFor(c, await installationToken(c), payload.workflow_job.id, req.onDemand);
238
+ return { statusCode: 201, body: id };
239
+ }
240
+
241
+ /** Queued jobs without a machine (lost webhook or cap reached) get one. */
242
+ export async function reconcile(c = config()) {
243
+ const token = await installationToken(c);
244
+ const queued = {};
245
+ for (const status of ['queued', 'in_progress']) {
246
+ const runs = await gh(`/repos/${c.repository}/actions/runs?status=${status}&per_page=50`, { token });
247
+ for (const run of runs.json?.workflow_runs ?? []) {
248
+ const jobs = await gh(`/repos/${c.repository}/actions/runs/${run.id}/jobs?per_page=100`, { token });
249
+ for (const j of jobs.json?.jobs ?? []) {
250
+ if (j.status !== 'queued') continue;
251
+ const req = runnerRequest({ action: 'queued', workflow_job: j }, c);
252
+ if (req) { const l = req.onDemand ? c.onDemandLabel : c.label; queued[l] = (queued[l] ?? 0) + 1; }
253
+ }
254
+ }
255
+ }
256
+ const runners = await gh(`/repos/${c.repository}/actions/runners?per_page=100`, { token });
257
+ const busy = {};
258
+ for (const r of runners.json?.runners ?? []) {
259
+ if (!r.busy) continue;
260
+ const names = (r.labels ?? []).map((x) => x.name);
261
+ const l = names.includes(c.onDemandLabel) ? c.onDemandLabel : names.includes(c.label) ? c.label : null;
262
+ if (l) busy[l] = (busy[l] ?? 0) + 1;
263
+ }
264
+ const active = await activeByLabel(c);
265
+ const missing = missingRunners({ queued, active, busy }, c);
266
+ console.log(JSON.stringify({ reconcile: { queued, active, busy, missing } }));
267
+ for (const [label, n] of Object.entries(missing)) {
268
+ for (let k = 0; k < n; k++) await launchFor(c, token, `reconcile-${Date.now().toString(36)}`, label === c.onDemandLabel);
269
+ }
270
+ return missing;
271
+ }
272
+
273
+ /** Checks the minutes used and flips the switch; when moving to AWS, re-runs the runs blocked for lack of minutes. */
274
+ export async function checkUsage(c = config(), now = new Date()) {
275
+ const token = await installationToken(c);
276
+ const s = await secret();
277
+ // Personal accounts: the user billing report needs a fine-grained token with "Plan: read" (`billingToken`).
278
+ const usage = await gh(usagePath(c.repository, c.billing), { token: s.billingToken || token });
279
+ const used = minutesThisMonth(usage.json, now);
280
+ const v = await gh(`/repos/${c.repository}/actions/variables/${c.switchVariable}`, { token });
281
+ const current = v.status === 404 ? 'github' : v.json.value;
282
+ const next = nextRunner(used, current, c);
283
+ console.log(JSON.stringify({ usedMinutes: used, current, next }));
284
+ if (!next) return { used, runner: current };
285
+ if (v.status === 404) await gh(`/repos/${c.repository}/actions/variables`, { token, method: 'POST', body: { name: c.switchVariable, value: next } });
286
+ else await gh(`/repos/${c.repository}/actions/variables/${c.switchVariable}`, { token, method: 'PATCH', body: { name: c.switchVariable, value: next } });
287
+ if (next === 'aws') {
288
+ const since = new Date(now.getTime() - 6 * 3600_000).toISOString();
289
+ const runs = await gh(`/repos/${c.repository}/actions/runs?status=failure&created=>=${since}&per_page=50`, { token });
290
+ for (const run of runs.json?.workflow_runs ?? []) {
291
+ const jobs = await gh(`/repos/${c.repository}/actions/runs/${run.id}/jobs`, { token });
292
+ // Without minutes GitHub does not even start the job: it fails with no step executed.
293
+ const blocked = (jobs.json?.jobs ?? []).some((j) => j.conclusion === 'failure' && (j.steps ?? []).length === 0);
294
+ if (blocked) await gh(`/repos/${c.repository}/actions/runs/${run.id}/rerun-failed-jobs`, { token, method: 'POST' }).catch((e) => console.log(e.message));
295
+ }
296
+ }
297
+ return { used, runner: next };
298
+ }
299
+
300
+ export async function handler(event) {
301
+ const c = config();
302
+ if (event?.task === 'check-usage') return checkUsage(c);
303
+ if (event?.task === 'reconcile') return reconcile(c);
304
+ try {
305
+ return await onWebhook(event, c);
306
+ } catch (e) {
307
+ console.error(e);
308
+ return { statusCode: 500, body: 'error' };
309
+ }
310
+ }
package/lib/index.d.ts ADDED
@@ -0,0 +1 @@
1
+ export * from './runner-switch';
package/lib/index.js ADDED
@@ -0,0 +1,17 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __exportStar = (this && this.__exportStar) || function(m, exports) {
14
+ for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
15
+ };
16
+ Object.defineProperty(exports, "__esModule", { value: true });
17
+ __exportStar(require("./runner-switch"), exports);
@@ -0,0 +1,83 @@
1
+ import { Duration } from 'aws-cdk-lib';
2
+ import * as ec2 from 'aws-cdk-lib/aws-ec2';
3
+ import * as lambda from 'aws-cdk-lib/aws-lambda';
4
+ import * as secretsmanager from 'aws-cdk-lib/aws-secretsmanager';
5
+ import { Construct } from 'constructs';
6
+ /** Where the GitHub Actions minutes are counted. */
7
+ export declare enum BillingScope {
8
+ /** Organization billing report (`/orgs/{org}/settings/billing/usage`), read with the GitHub App token. */
9
+ ORGANIZATION = "organization",
10
+ /** Personal account billing report; needs a fine-grained token with "Plan: read" stored as `billingToken`. */
11
+ USER = "user"
12
+ }
13
+ export interface GitHubRunnerSwitchProps {
14
+ /** VPC for the runner machines. They need outbound internet access; by default they use public subnets (no NAT). */
15
+ readonly vpc: ec2.IVpc;
16
+ /** @default - public subnets */
17
+ readonly subnets?: ec2.SubnetSelection;
18
+ /** Repository served by the runners, as `owner/name`. */
19
+ readonly repository: string;
20
+ /** @default BillingScope.ORGANIZATION */
21
+ readonly billing?: BillingScope;
22
+ /** Label of the Spot runners (`runs-on: [self-hosted, linux, arm64, <label>]`). @default 'aws' */
23
+ readonly label?: string;
24
+ /** Label of the on-demand runners (e.g. deploy jobs that must not be interrupted). @default `${label}-ondemand` */
25
+ readonly onDemandLabel?: string;
26
+ /** Graviton (arm64) instance types, in order of preference. @default ['m7g.large', 'm6g.large', 'm8g.large'] */
27
+ readonly instanceTypes?: string[];
28
+ /** Minutes used this month at which the switch moves to AWS. @default 1900 (free plan: 2,000) */
29
+ readonly thresholdMinutes?: number;
30
+ /** Maximum machines at the same time. @default 6 */
31
+ readonly maxInstances?: number;
32
+ /** Repository variable flipped between `github` and `aws`. @default 'RUNNER' */
33
+ readonly switchVariable?: string;
34
+ /** Runner group of the JIT registrations. @default 1 (Default) */
35
+ readonly runnerGroupId?: number;
36
+ /** Safety net: the machine shuts down after this long even if the job hangs. @default Duration.hours(2) */
37
+ readonly maxJobDuration?: Duration;
38
+ /** Root disk in GiB. @default 40 */
39
+ readonly rootVolumeSize?: number;
40
+ /** Node.js major version installed in the image. @default 24 */
41
+ readonly nodeMajorVersion?: number;
42
+ /** Container images pulled into the image ahead of time (faster `services:`). @default [] */
43
+ readonly cachedImages?: string[];
44
+ /** Extra shell commands run as root while building the image (e.g. `npx -y playwright install-deps chromium`). @default [] */
45
+ readonly extraImageCommands?: string[];
46
+ /** When the image is rebuilt (EventBridge cron, UTC). @default 'cron(0 5 1 * ? *)' — monthly, day 1 at 05:00 */
47
+ readonly imageSchedule?: string;
48
+ /** @default Duration.minutes(5) */
49
+ readonly reconcileInterval?: Duration;
50
+ /** @default Duration.minutes(30) */
51
+ readonly usageCheckInterval?: Duration;
52
+ }
53
+ /**
54
+ * GitHub Actions runners that switch between GitHub-hosted (free minutes) and disposable EC2 Spot machines on AWS when
55
+ * the monthly minutes run out, and back when the month resets.
56
+ *
57
+ * - A GitHub App sends `workflow_job` webhooks to a Lambda Function URL; each queued job gets its own machine with a
58
+ * single-use (JIT) runner registration. The machine terminates when the job ends.
59
+ * - The Lambda flips a repository variable (`RUNNER`) by the minutes used; workflows pick the runner with
60
+ * `runs-on: ${{ vars.RUNNER == 'aws' && fromJSON('["self-hosted","linux","arm64","aws"]') || 'ubuntu-24.04-arm' }}`.
61
+ * - An EC2 Image Builder pipeline rebuilds the Ubuntu 24.04 arm64 image on a schedule.
62
+ */
63
+ export declare class GitHubRunnerSwitch extends Construct {
64
+ /** URL to configure as the GitHub App webhook. */
65
+ readonly webhookUrl: string;
66
+ /** Secret holding `webhookSecret` (generated), `appId` and `privateKey` (filled by the setup script). */
67
+ readonly secret: secretsmanager.ISecret;
68
+ /** SSM parameter with the current runner AMI (written by Image Builder). */
69
+ readonly amiParameterName: string;
70
+ /** Image Builder pipeline: run it once after the first deploy. */
71
+ readonly imagePipelineArn: string;
72
+ /** The control function (webhook, reconciliation and usage check). */
73
+ readonly function: lambda.IFunction;
74
+ /** Security group of the runner machines (no inbound rules). */
75
+ readonly securityGroup: ec2.ISecurityGroup;
76
+ constructor(scope: Construct, id: string, props: GitHubRunnerSwitchProps);
77
+ }
78
+ /** Image Builder component (YAML). Step names: letters, digits, `-` and `_` only. */
79
+ export declare function runnerComponent(o: {
80
+ nodeMajorVersion: number;
81
+ cachedImages: string[];
82
+ extraCommands: string[];
83
+ }): string;
@@ -0,0 +1,257 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.GitHubRunnerSwitch = exports.BillingScope = void 0;
37
+ exports.runnerComponent = runnerComponent;
38
+ const crypto = __importStar(require("crypto"));
39
+ const path = __importStar(require("path"));
40
+ const aws_cdk_lib_1 = require("aws-cdk-lib");
41
+ const ec2 = __importStar(require("aws-cdk-lib/aws-ec2"));
42
+ const events = __importStar(require("aws-cdk-lib/aws-events"));
43
+ const targets = __importStar(require("aws-cdk-lib/aws-events-targets"));
44
+ const iam = __importStar(require("aws-cdk-lib/aws-iam"));
45
+ const imagebuilder = __importStar(require("aws-cdk-lib/aws-imagebuilder"));
46
+ const lambda = __importStar(require("aws-cdk-lib/aws-lambda"));
47
+ const logs = __importStar(require("aws-cdk-lib/aws-logs"));
48
+ const secretsmanager = __importStar(require("aws-cdk-lib/aws-secretsmanager"));
49
+ const constructs_1 = require("constructs");
50
+ /** Where the GitHub Actions minutes are counted. */
51
+ var BillingScope;
52
+ (function (BillingScope) {
53
+ /** Organization billing report (`/orgs/{org}/settings/billing/usage`), read with the GitHub App token. */
54
+ BillingScope["ORGANIZATION"] = "organization";
55
+ /** Personal account billing report; needs a fine-grained token with "Plan: read" stored as `billingToken`. */
56
+ BillingScope["USER"] = "user";
57
+ })(BillingScope || (exports.BillingScope = BillingScope = {}));
58
+ /**
59
+ * GitHub Actions runners that switch between GitHub-hosted (free minutes) and disposable EC2 Spot machines on AWS when
60
+ * the monthly minutes run out, and back when the month resets.
61
+ *
62
+ * - A GitHub App sends `workflow_job` webhooks to a Lambda Function URL; each queued job gets its own machine with a
63
+ * single-use (JIT) runner registration. The machine terminates when the job ends.
64
+ * - The Lambda flips a repository variable (`RUNNER`) by the minutes used; workflows pick the runner with
65
+ * `runs-on: ${{ vars.RUNNER == 'aws' && fromJSON('["self-hosted","linux","arm64","aws"]') || 'ubuntu-24.04-arm' }}`.
66
+ * - An EC2 Image Builder pipeline rebuilds the Ubuntu 24.04 arm64 image on a schedule.
67
+ */
68
+ class GitHubRunnerSwitch extends constructs_1.Construct {
69
+ /** URL to configure as the GitHub App webhook. */
70
+ webhookUrl;
71
+ /** Secret holding `webhookSecret` (generated), `appId` and `privateKey` (filled by the setup script). */
72
+ secret;
73
+ /** SSM parameter with the current runner AMI (written by Image Builder). */
74
+ amiParameterName;
75
+ /** Image Builder pipeline: run it once after the first deploy. */
76
+ imagePipelineArn;
77
+ /** The control function (webhook, reconciliation and usage check). */
78
+ function;
79
+ /** Security group of the runner machines (no inbound rules). */
80
+ securityGroup;
81
+ constructor(scope, id, props) {
82
+ super(scope, id);
83
+ if (!/^[\w.-]+\/[\w.-]+$/.test(props.repository))
84
+ throw new Error(`repository must be "owner/name", got "${props.repository}"`);
85
+ const name = aws_cdk_lib_1.Names.uniqueResourceName(this, { maxLength: 48, separator: '-' }).toLowerCase();
86
+ const label = props.label ?? 'aws';
87
+ const onDemandLabel = props.onDemandLabel ?? `${label}-ondemand`;
88
+ const instanceTypes = props.instanceTypes ?? ['m7g.large', 'm6g.large', 'm8g.large'];
89
+ if (!instanceTypes.length)
90
+ throw new Error('instanceTypes must not be empty');
91
+ const rootVolumeSize = props.rootVolumeSize ?? 40;
92
+ // Image Builder can only write SSM parameters under /imagebuilder/ (its service role).
93
+ this.amiParameterName = `/imagebuilder/${name}/ami`;
94
+ const subnets = props.vpc.selectSubnets(props.subnets ?? { subnetType: ec2.SubnetType.PUBLIC });
95
+ // Runner machine: no inbound port, outbound only.
96
+ const sg = new ec2.SecurityGroup(this, 'RunnerSecurityGroup', { vpc: props.vpc, description: 'GitHub runners: no inbound', allowAllOutbound: true });
97
+ this.securityGroup = sg;
98
+ const instanceRole = new iam.Role(this, 'RunnerRole', {
99
+ assumedBy: new iam.ServicePrincipal('ec2.amazonaws.com'),
100
+ description: 'GitHub runner machine: Session Manager access only (jobs use their own OIDC role)',
101
+ managedPolicies: [iam.ManagedPolicy.fromAwsManagedPolicyName('AmazonSSMManagedInstanceCore')],
102
+ });
103
+ const template = new ec2.LaunchTemplate(this, 'RunnerTemplate', {
104
+ role: instanceRole,
105
+ instanceInitiatedShutdownBehavior: ec2.InstanceInitiatedShutdownBehavior.TERMINATE,
106
+ requireImdsv2: true,
107
+ blockDevices: [{
108
+ deviceName: '/dev/sda1',
109
+ volume: ec2.BlockDeviceVolume.ebs(rootVolumeSize, { volumeType: ec2.EbsDeviceVolumeType.GP3, encrypted: true, deleteOnTermination: true }),
110
+ }],
111
+ });
112
+ // Image: Canonical Ubuntu 24.04 arm64 + runner tooling. Components are immutable, so the version follows the content.
113
+ const componentData = runnerComponent({
114
+ nodeMajorVersion: props.nodeMajorVersion ?? 24, cachedImages: props.cachedImages ?? [], extraCommands: props.extraImageCommands ?? [],
115
+ });
116
+ const version = `1.0.${parseInt(crypto.createHash('sha256').update(componentData).digest('hex').slice(0, 7), 16) % 1_000_000}`;
117
+ const builderRole = new iam.Role(this, 'ImageBuilderRole', {
118
+ assumedBy: new iam.ServicePrincipal('ec2.amazonaws.com'),
119
+ managedPolicies: [
120
+ iam.ManagedPolicy.fromAwsManagedPolicyName('AmazonSSMManagedInstanceCore'),
121
+ iam.ManagedPolicy.fromAwsManagedPolicyName('EC2InstanceProfileForImageBuilder'),
122
+ ],
123
+ });
124
+ const builderProfile = new iam.CfnInstanceProfile(this, 'ImageBuilderProfile', { roles: [builderRole.roleName] });
125
+ const component = new imagebuilder.CfnComponent(this, 'RunnerComponent', { name, platform: 'Linux', version, data: componentData });
126
+ const recipe = new imagebuilder.CfnImageRecipe(this, 'RunnerRecipe', {
127
+ name, version,
128
+ parentImage: 'ssm:/aws/service/canonical/ubuntu/server/24.04/stable/current/arm64/hvm/ebs-gp3/ami-id',
129
+ components: [{ componentArn: component.attrArn }],
130
+ blockDeviceMappings: [{ deviceName: '/dev/sda1', ebs: { volumeSize: rootVolumeSize, volumeType: 'gp3', deleteOnTermination: true } }],
131
+ });
132
+ const infra = new imagebuilder.CfnInfrastructureConfiguration(this, 'RunnerBuildInfrastructure', {
133
+ name, instanceProfileName: builderProfile.ref, instanceTypes: instanceTypes.slice(0, 2),
134
+ subnetId: subnets.subnetIds[0], securityGroupIds: [sg.securityGroupId], terminateInstanceOnFailure: true,
135
+ });
136
+ const distribution = new imagebuilder.CfnDistributionConfiguration(this, 'RunnerDistribution', {
137
+ name,
138
+ distributions: [{
139
+ region: aws_cdk_lib_1.Stack.of(this).region,
140
+ amiDistributionConfiguration: { Name: `${name}-{{ imagebuilder:buildDate }}` },
141
+ ssmParameterConfigurations: [{ parameterName: this.amiParameterName, dataType: 'aws:ec2:image' }],
142
+ }],
143
+ });
144
+ const pipeline = new imagebuilder.CfnImagePipeline(this, 'RunnerImagePipeline', {
145
+ name,
146
+ imageRecipeArn: recipe.attrArn, infrastructureConfigurationArn: infra.attrArn, distributionConfigurationArn: distribution.attrArn,
147
+ schedule: { scheduleExpression: props.imageSchedule ?? 'cron(0 5 1 * ? *)', pipelineExecutionStartCondition: 'EXPRESSION_MATCH_ONLY' },
148
+ imageTestsConfiguration: { imageTestsEnabled: false },
149
+ });
150
+ this.imagePipelineArn = pipeline.attrArn;
151
+ // GitHub App credentials: the webhook secret is generated here; the setup script adds appId and privateKey.
152
+ const secret = new secretsmanager.Secret(this, 'AppSecret', {
153
+ description: `GitHub App of the runners for ${props.repository} (cdk-github-runner-switch)`,
154
+ generateSecretString: {
155
+ secretStringTemplate: JSON.stringify({ appId: '', privateKey: '' }),
156
+ generateStringKey: 'webhookSecret', excludePunctuation: true, passwordLength: 40,
157
+ },
158
+ });
159
+ this.secret = secret;
160
+ const fn = new lambda.Function(this, 'Control', {
161
+ runtime: lambda.Runtime.NODEJS_22_X,
162
+ architecture: lambda.Architecture.ARM_64,
163
+ handler: 'index.handler',
164
+ code: lambda.Code.fromAsset(path.join(__dirname, '..', 'lambda')),
165
+ timeout: aws_cdk_lib_1.Duration.seconds(60),
166
+ memorySize: 256,
167
+ logGroup: new logs.LogGroup(this, 'ControlLogs', { retention: logs.RetentionDays.ONE_MONTH, removalPolicy: aws_cdk_lib_1.RemovalPolicy.DESTROY }),
168
+ description: `GitHub runner switch for ${props.repository}: webhook, reconciliation and minutes check`,
169
+ environment: {
170
+ SECRET_ID: secret.secretArn,
171
+ REPOSITORY: props.repository,
172
+ BILLING: props.billing ?? BillingScope.ORGANIZATION,
173
+ LABEL: label,
174
+ ON_DEMAND_LABEL: onDemandLabel,
175
+ INSTANCE_TYPES: instanceTypes.join(','),
176
+ THRESHOLD_MINUTES: String(props.thresholdMinutes ?? 1900),
177
+ MAX_INSTANCES: String(props.maxInstances ?? 6),
178
+ SWITCH_VARIABLE: props.switchVariable ?? 'RUNNER',
179
+ RUNNER_GROUP_ID: String(props.runnerGroupId ?? 1),
180
+ MAX_JOB_MINUTES: String((props.maxJobDuration ?? aws_cdk_lib_1.Duration.hours(2)).toMinutes()),
181
+ SWITCH_ID: name,
182
+ AMI_PARAMETER: this.amiParameterName,
183
+ LAUNCH_TEMPLATE_ID: template.launchTemplateId,
184
+ SECURITY_GROUP_ID: sg.securityGroupId,
185
+ SUBNET_IDS: subnets.subnetIds.join(','),
186
+ },
187
+ });
188
+ this.function = fn;
189
+ secret.grantRead(fn);
190
+ const { region, account } = aws_cdk_lib_1.Stack.of(this);
191
+ fn.addToRolePolicy(new iam.PolicyStatement({ actions: ['ssm:GetParameter'], resources: [`arn:aws:ssm:${region}:${account}:parameter${this.amiParameterName}`] }));
192
+ fn.addToRolePolicy(new iam.PolicyStatement({ actions: ['ec2:RunInstances', 'ec2:CreateTags', 'ec2:DescribeInstances'], resources: ['*'] }));
193
+ fn.addToRolePolicy(new iam.PolicyStatement({ actions: ['iam:PassRole'], resources: [instanceRole.roleArn] }));
194
+ this.webhookUrl = fn.addFunctionUrl({ authType: lambda.FunctionUrlAuthType.NONE }).url;
195
+ new events.Rule(this, 'UsageCheck', {
196
+ schedule: events.Schedule.rate(props.usageCheckInterval ?? aws_cdk_lib_1.Duration.minutes(30)),
197
+ targets: [new targets.LambdaFunction(fn, { event: events.RuleTargetInput.fromObject({ task: 'check-usage' }), retryAttempts: 1 })],
198
+ });
199
+ new events.Rule(this, 'Reconcile', {
200
+ schedule: events.Schedule.rate(props.reconcileInterval ?? aws_cdk_lib_1.Duration.minutes(5)),
201
+ targets: [new targets.LambdaFunction(fn, { event: events.RuleTargetInput.fromObject({ task: 'reconcile' }), retryAttempts: 0 })],
202
+ });
203
+ }
204
+ }
205
+ exports.GitHubRunnerSwitch = GitHubRunnerSwitch;
206
+ /** Image Builder component (YAML). Step names: letters, digits, `-` and `_` only. */
207
+ function runnerComponent(o) {
208
+ const list = (cmds) => cmds.map((c) => ` - ${JSON.stringify(c)}`).join('\n');
209
+ const steps = [` - name: Packages
210
+ action: ExecuteBash
211
+ inputs:
212
+ commands:
213
+ ${list([
214
+ 'export DEBIAN_FRONTEND=noninteractive',
215
+ 'apt-get update',
216
+ 'apt-get install -y ca-certificates curl git jq unzip zip build-essential python3 python3-pip docker.io',
217
+ 'systemctl enable docker',
218
+ `curl -fsSL https://deb.nodesource.com/setup_${o.nodeMajorVersion}.x | bash -`,
219
+ 'apt-get install -y nodejs',
220
+ 'curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg -o /usr/share/keyrings/githubcli-archive-keyring.gpg',
221
+ 'echo "deb [arch=arm64 signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" > /etc/apt/sources.list.d/github-cli.list',
222
+ 'apt-get update && apt-get install -y gh',
223
+ 'curl -fsSL https://awscli.amazonaws.com/awscli-exe-linux-aarch64.zip -o /tmp/awscli.zip && unzip -q /tmp/awscli.zip -d /tmp && /tmp/aws/install && rm -rf /tmp/aws /tmp/awscli.zip',
224
+ ])}`, ` - name: Runner
225
+ action: ExecuteBash
226
+ inputs:
227
+ commands:
228
+ ${list([
229
+ 'id runner || useradd -m -s /bin/bash runner',
230
+ 'usermod -aG docker runner',
231
+ 'echo "runner ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/runner && chmod 440 /etc/sudoers.d/runner',
232
+ 'V=$(curl -fsSL https://api.github.com/repos/actions/runner/releases/latest | jq -r .tag_name | sed "s/^v//")',
233
+ 'mkdir -p /opt/actions-runner && cd /opt/actions-runner && curl -fsSL "https://github.com/actions/runner/releases/download/v$V/actions-runner-linux-arm64-$V.tar.gz" | tar xz',
234
+ '/opt/actions-runner/bin/installdependencies.sh',
235
+ 'chown -R runner:runner /opt/actions-runner',
236
+ ])}`];
237
+ if (o.cachedImages.length)
238
+ steps.push(` - name: CachedImages
239
+ action: ExecuteBash
240
+ inputs:
241
+ commands:
242
+ ${list(o.cachedImages.map((i) => `docker pull ${i}`))}`);
243
+ if (o.extraCommands.length)
244
+ steps.push(` - name: Extra
245
+ action: ExecuteBash
246
+ inputs:
247
+ commands:
248
+ ${list(o.extraCommands)}`);
249
+ return `name: github-runner
250
+ description: GitHub Actions runner (cdk-github-runner-switch)
251
+ schemaVersion: 1.0
252
+ phases:
253
+ - name: build
254
+ steps:
255
+ ${steps.join('\n')}
256
+ `;
257
+ }
package/package.json CHANGED
@@ -1,6 +1,58 @@
1
1
  {
2
2
  "name": "cdk-github-runner-switch",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.1",
4
+ "description": "AWS CDK construct: GitHub Actions runners that switch from GitHub-hosted free minutes to disposable EC2 Spot (Graviton) machines when the monthly minutes run out — and back when the month resets.",
5
+ "license": "Apache-2.0",
6
+ "author": "uarch-solutions",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/uarch-solutions/cdk-github-runner-switch.git"
10
+ },
11
+ "homepage": "https://github.com/uarch-solutions/cdk-github-runner-switch#readme",
12
+ "bugs": {
13
+ "url": "https://github.com/uarch-solutions/cdk-github-runner-switch/issues"
14
+ },
15
+ "keywords": [
16
+ "aws-cdk",
17
+ "cdk",
18
+ "construct",
19
+ "github-actions",
20
+ "self-hosted-runner",
21
+ "github-runner",
22
+ "ec2-spot",
23
+ "graviton",
24
+ "arm64",
25
+ "ci",
26
+ "cost-optimization"
27
+ ],
28
+ "main": "lib/index.js",
29
+ "types": "lib/index.d.ts",
30
+ "files": [
31
+ "lib",
32
+ "lambda",
33
+ "scripts",
34
+ "README.md",
35
+ "README.pt-BR.md",
36
+ "LICENSE",
37
+ "NOTICE"
38
+ ],
39
+ "engines": {
40
+ "node": ">=20"
41
+ },
42
+ "scripts": {
43
+ "build": "tsc",
44
+ "test": "vitest run",
45
+ "prepublishOnly": "npm run build && npm test"
46
+ },
47
+ "peerDependencies": {
48
+ "aws-cdk-lib": "^2.180.0",
49
+ "constructs": "^10.0.0"
50
+ },
51
+ "devDependencies": {
52
+ "@types/node": "^22.0.0",
53
+ "aws-cdk-lib": "^2.271.0",
54
+ "constructs": "^10.4.0",
55
+ "typescript": "^5.8.0",
56
+ "vitest": "^3.2.0"
57
+ }
58
+ }
@@ -0,0 +1,43 @@
1
+ #!/usr/bin/env bash
2
+ # Connects the GitHub App to a deployed GitHubRunnerSwitch without the private key going through chat, shell history
3
+ # or git. Run it in your own terminal, with AWS credentials for the account of the stack.
4
+ #
5
+ # scripts/setup-github-app.sh <stack-name> [region]
6
+ #
7
+ # The stack must export the construct values as outputs named WebhookUrl and SecretArn (see examples/app.ts).
8
+ set -euo pipefail
9
+ STACK="${1:?usage: setup-github-app.sh <stack-name> [region]}"
10
+ REGION="${2:-${AWS_REGION:-${AWS_DEFAULT_REGION:-}}}"
11
+ [ -n "$REGION" ] || { echo "Set the region (second argument or AWS_REGION)." >&2; exit 2; }
12
+ export AWS_REGION="$REGION" AWS_DEFAULT_REGION="$REGION"
13
+ out() { aws cloudformation describe-stacks --stack-name "$STACK" --query "Stacks[0].Outputs[?OutputKey=='$1'].OutputValue" --output text; }
14
+ URL="$(out WebhookUrl)"; SECRET="$(out SecretArn)"
15
+ [ -n "$URL" ] && [ "$URL" != None ] && [ -n "$SECRET" ] && [ "$SECRET" != None ] || { echo "Outputs WebhookUrl/SecretArn not found in $STACK." >&2; exit 2; }
16
+ CURRENT="$(aws secretsmanager get-secret-value --secret-id "$SECRET" --query SecretString --output text)"
17
+ WEBHOOK_SECRET="$(printf '%s' "$CURRENT" | python3 -c 'import json,sys; print(json.load(sys.stdin)["webhookSecret"])')"
18
+
19
+ cat <<MSG
20
+
21
+ 1) Create a GitHub App (organization: Settings → Developer settings → GitHub Apps → New GitHub App)
22
+ - Webhook: active · URL: $URL
23
+ Webhook secret: $WEBHOOK_SECRET
24
+ - Repository permissions: Administration (read & write), Actions (read & write), Variables (read & write), Metadata (read)
25
+ - Organization permissions: Administration (read) ← billing usage report
26
+ - Subscribe to events: Workflow job · Where can it be installed: Only on this account
27
+ 2) Generate a private key (.pem) and install the App on the repository only.
28
+
29
+ MSG
30
+ read -rp "App ID: " APP_ID
31
+ read -rp "Path to the downloaded .pem: " PEM
32
+ read -rp "Personal account only — fine-grained token with 'Plan: read' (leave empty for organizations): " -s BILLING_TOKEN; echo
33
+ [[ "$APP_ID" =~ ^[0-9]+$ ]] && [ -f "$PEM" ] || { echo "Invalid App ID or file; nothing changed." >&2; exit 2; }
34
+
35
+ TMP="$(mktemp)"; trap 'shred -u "$TMP" 2>/dev/null || rm -f "$TMP"' EXIT
36
+ APP_ID="$APP_ID" PEM="$PEM" BILLING_TOKEN="$BILLING_TOKEN" CURRENT="$CURRENT" python3 -c '
37
+ import json, os
38
+ s = json.loads(os.environ["CURRENT"])
39
+ s.update(appId=os.environ["APP_ID"], privateKey=open(os.environ["PEM"]).read())
40
+ if os.environ["BILLING_TOKEN"]: s["billingToken"] = os.environ["BILLING_TOKEN"]
41
+ print(json.dumps(s))' > "$TMP"
42
+ aws secretsmanager put-secret-value --secret-id "$SECRET" --secret-string "file://$TMP" --query VersionId --output text >/dev/null
43
+ echo "Done: the function picks it up within 5 minutes. You can delete the .pem ($PEM)."