1import * as Alchemy from "alchemy";
2import * as AWS from "alchemy/AWS";
3import * as Cloudflare from "alchemy/Cloudflare";
4import * as GitHub from "alchemy/GitHub";
5import * as Output from "alchemy/Output";
6import * as Config from "effect/Config";
7import * as Effect from "effect/Effect";
8import * as Layer from "effect/Layer";
9import * as Redacted from "effect/Redacted";
10
11const REPO = { owner: "alchemy-run", repository: "alchemy" } as const;
12
13/**
14 * Repository secrets consumed by `.github/workflows`, plus the AWS OIDC trust
15 * behind `AWS_ROLE_ARN`. Inputs come from Doppler: `pnpm deploy:github` runs
16 * under `doppler run -c prod`. Cloudflare API tokens are minted here rather
17 * than copied.
18 *
19 * Secrets the workflows read that have no Doppler source stay hand-managed
20 * in the repository settings:
21 * - `ALCHEMY_VERSION_BOT_ID`, `ALCHEMY_VERSION_BOT_PRIVATE_KEY` (release.yml, website.yml)
22 * - `NPM_TOKEN` (release.yml)
23 * - `TEST_D1_DATABASE_ID`, `TEST_KV_NAMESPACE_ID`, `TEST_R2_BUCKET_NAME`,
24 * `TEST_SERVICE_WORKER_NAME`, `TEST_MYSQL_URL`, `TEST_POSTGRES_URL` (cloudflare-tools.yml)
25 */
26export default Alchemy.Stack(
27 "AlchemyGitHubSecrets",
28 {
29 providers: Layer.mergeAll(
30 AWS.providers(),
31 Cloudflare.providers(),
32 GitHub.providers(),
33 ),
34 state: Cloudflare.state(),
35 },
36 Effect.gen(function* () {
37 const CLOUDFLARE_API_TOKEN = yield* Config.Redacted("CLOUDFLARE_API_TOKEN");
38 const TEST_CLOUDFLARE_ACCOUNT_ID = yield* Config.String(
39 "TEST_CLOUDFLARE_ACCOUNT_ID",
40 );
41 const PROD_CLOUDFLARE_ACCOUNT_ID = yield* Config.String(
42 "PROD_CLOUDFLARE_ACCOUNT_ID",
43 );
44 const ANTHROPIC_API_KEY = yield* Config.Redacted("ANTHROPIC_API_KEY");
45 const DISCORD_WEBHOOK_URL = yield* Config.Redacted("DISCORD_WEBHOOK_URL");
46
47 // The prod account token is minted with the admin token from Doppler; the
48 // test account token with the profile's own credentials.
49 const PROD_CLOUDFLARE_API_TOKEN = yield* AccountApiToken("ProdApiToken", {
50 accountId: PROD_CLOUDFLARE_ACCOUNT_ID,
51 }).pipe(
52 Effect.provide(
53 Layer.succeed(
54 Cloudflare.Credentials,
55 Effect.succeed({
56 type: "apiToken",
57 apiToken: CLOUDFLARE_API_TOKEN,
58 apiBaseUrl: "https://api.cloudflare.com",
59 }),
60 ),
61 ),
62 );
63 const TEST_CLOUDFLARE_API_TOKEN = yield* AccountApiToken("TestApiToken", {
64 accountId: TEST_CLOUDFLARE_ACCOUNT_ID,
65 });
66
67 // GitHub OIDC trust for AWS — lets workflows assume an IAM role via
68 // `aws-actions/configure-aws-credentials` with no long-lived AWS keys.
69 const oidc = yield* AWS.IAM.OpenIDConnectProvider("GitHubOidc", {
70 url: "https://token.actions.githubusercontent.com",
71 clientIDList: ["sts.amazonaws.com"],
72 // GitHub's well-known OIDC thumbprint. AWS auto-discovers thumbprints
73 // for github.com, but the thumbprint sync still requires a non-empty
74 // list when comparing against the cloud-observed value.
75 thumbprintList: ["6938fd4d98bab03faadb97b34396831e3780aea1"],
76 });
77
78 const role = yield* AWS.IAM.Role("GitHubActionsRole", {
79 roleName: "alchemy-github-actions",
80 assumeRolePolicyDocument: {
81 Version: "2012-10-17",
82 Statement: [
83 {
84 Effect: "Allow",
85 Principal: {
86 Federated: oidc.openIDConnectProviderArn,
87 },
88 Action: ["sts:AssumeRoleWithWebIdentity"],
89 Condition: {
90 StringEquals: {
91 "token.actions.githubusercontent.com:aud": "sts.amazonaws.com",
92 },
93 // Any branch / PR / tag inside this repo. Tighten to
94 // `repo:.../environment:prod` once GitHub Environments are used.
95 StringLike: {
96 "token.actions.githubusercontent.com:sub": `repo:${REPO.owner}/${REPO.repository}:*`,
97 },
98 },
99 },
100 ],
101 },
102 // The smoke suite deploys real Lambdas, S3 buckets, DynamoDB tables,
103 // etc., so it needs broad access.
104 managedPolicyArns: ["arn:aws:iam::aws:policy/AdministratorAccess"],
105 });
106
107 yield* GitHub.Secrets({
108 ...REPO,
109 secrets: {
110 // check.yml, claude.yml, website.yml previews
111 TEST_CLOUDFLARE_ACCOUNT_ID,
112 TEST_CLOUDFLARE_API_TOKEN: TEST_CLOUDFLARE_API_TOKEN.value,
113 // website.yml production deploy
114 PROD_CLOUDFLARE_ACCOUNT_ID,
115 PROD_CLOUDFLARE_API_TOKEN: PROD_CLOUDFLARE_API_TOKEN.value,
116 // cloudflare-tools.yml runs the runtime packages' suites against the
117 // test account under the unprefixed names.
118 CLOUDFLARE_ACCOUNT_ID: TEST_CLOUDFLARE_ACCOUNT_ID,
119 CLOUDFLARE_API_TOKEN: TEST_CLOUDFLARE_API_TOKEN.value,
120 // claude.yml
121 AWS_ROLE_ARN: role.roleArn,
122 ANTHROPIC_API_KEY,
123 // release.yml
124 DISCORD_WEBHOOK_URL,
125 },
126 });
127
128 return {
129 TEST_CLOUDFLARE_ACCOUNT_ID,
130 TEST_CLOUDFLARE_API_TOKEN: TEST_CLOUDFLARE_API_TOKEN.value.pipe(
131 Output.map(Redacted.value),
132 ),
133 PROD_CLOUDFLARE_ACCOUNT_ID,
134 PROD_CLOUDFLARE_API_TOKEN: PROD_CLOUDFLARE_API_TOKEN.value.pipe(
135 Output.map(Redacted.value),
136 ),
137 AWS_ROLE_ARN: role.roleArn,
138 };
139 }).pipe(Effect.orDie),
140);
141
142const AccountApiToken = (
143 id: string,
144 props: {
145 accountId: string;
146 },
147) =>
148 Cloudflare.ApiToken.AccountApiToken(id, {
149 name: "alchemy-ci",
150 accountId: props.accountId,
151 policies: [
152 {
153 effect: "allow",
154 permissionGroups: [
155 // Worker / runtime data plane
156 "Workers Scripts Write",
157 "Workers KV Storage Write",
158 "Workers R2 Storage Write",
159 "Workers Routes Write",
160 "Workers Tail Read",
161 "Workers Observability Write",
162 // Storage / data services
163 "D1 Write",
164 "Queues Write",
165 "Hyperdrive Write",
166 "Pipelines Write",
167 "Vectorize Write",
168 // Higher-level Worker features used by examples
169 "AI Gateway Write",
170 // Containers
171 "Workers Containers Write",
172 "Cloudchamber Write",
173 "Browser Rendering Write",
174 // Static assets / sites
175 "Pages Write",
176 // Misc
177 "Account Settings Write",
178 "Secrets Store Write",
179 "Logs Write",
180 ],
181 resources: {
182 [`com.cloudflare.api.account.${props.accountId}`]: "*",
183 },
184 },
185 ],
186 });