| 1 | import * as Alchemy from "alchemy"; |
| 2 | import * as AWS from "alchemy/AWS"; |
| 3 | import * as Cloudflare from "alchemy/Cloudflare"; |
| 4 | import * as GitHub from "alchemy/GitHub"; |
| 5 | import * as Output from "alchemy/Output"; |
| 6 | import * as Config from "effect/Config"; |
| 7 | import * as Effect from "effect/Effect"; |
| 8 | import * as Layer from "effect/Layer"; |
| 9 | import * as Redacted from "effect/Redacted"; |
| 10 | |
| 11 | const 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 | */ |
| 26 | export 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 | |
| 142 | const 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 | }); |