| 1 | --- |
| 2 | title: Actions secrets & variables |
| 3 | description: Manage GitHub Actions secrets and variables as Stack resources — encrypted secrets, plain-text variables, bulk helpers, and CI credentials provisioned as code. |
| 4 | --- |
| 5 | |
| 6 | GitHub Actions workflows read configuration from two places: |
| 7 | **secrets** (encrypted, never readable back) and **variables** |
| 8 | (plain text, visible in logs). The GitHub provider manages both as |
| 9 | resources, so the values CI needs — API tokens, role ARNs, regions — |
| 10 | are declared next to the infrastructure that produces them instead |
| 11 | of pasted into repo settings. |
| 12 | |
| 13 | Use `Secret` for anything sensitive and `Variable` for everything |
| 14 | else. `Secrets` and `Variables` are bulk helpers over the same |
| 15 | resources. |
| 16 | |
| 17 | ## Create a secret |
| 18 | |
| 19 | `GitHub.Secret` manages one encrypted secret. The value must be |
| 20 | wrapped with `Redacted.make` so it never appears in logs or state: |
| 21 | |
| 22 | ```typescript |
| 23 | import * as GitHub from "alchemy/GitHub"; |
| 24 | import * as Redacted from "effect/Redacted"; |
| 25 | |
| 26 | yield* GitHub.Secret("deploy-token", { |
| 27 | owner: "my-org", |
| 28 | repository: "my-repo", |
| 29 | name: "DEPLOY_TOKEN", |
| 30 | value: Redacted.make("my-secret-value"), |
| 31 | }); |
| 32 | ``` |
| 33 | |
| 34 | The value is encrypted client-side with the repository's public key |
| 35 | (via libsodium) before it's sent to GitHub. The resource is |
| 36 | idempotent — redeploying with the same name updates the secret in |
| 37 | place, and destroying the stack deletes it. |
| 38 | |
| 39 | GitHub never returns a secret's value, so there's nothing to diff: |
| 40 | alchemy re-encrypts and re-uploads on every deploy. |
| 41 | |
| 42 | ## Scope a secret to an environment |
| 43 | |
| 44 | Pass `environment` to scope the secret to a GitHub Actions |
| 45 | environment (e.g. `production`) instead of the whole repository: |
| 46 | |
| 47 | ```typescript |
| 48 | yield* GitHub.Secret("deploy-key", { |
| 49 | owner: "my-org", |
| 50 | repository: "my-repo", |
| 51 | environment: "production", |
| 52 | name: "DEPLOY_KEY", |
| 53 | value: Redacted.make("my-secret-value"), |
| 54 | }); |
| 55 | ``` |
| 56 | |
| 57 | Environment secrets are only released to workflows once the |
| 58 | environment's protection rules are satisfied. |
| 59 | |
| 60 | ## Manage the environment itself |
| 61 | |
| 62 | `GitHub.Environment` manages the deployment environment as a resource — |
| 63 | including its protection rules — so the environment a secret targets is |
| 64 | declared in the same deploy: |
| 65 | |
| 66 | ```typescript |
| 67 | const production = yield* GitHub.Environment("production", { |
| 68 | owner: "my-org", |
| 69 | repository: "my-repo", |
| 70 | name: "production", |
| 71 | waitTimer: 30, |
| 72 | preventSelfReview: true, |
| 73 | reviewers: { |
| 74 | users: ["release-manager"], |
| 75 | teams: ["platform"], |
| 76 | }, |
| 77 | deploymentBranchPolicy: { |
| 78 | customBranchPolicies: ["main", "release/*"], |
| 79 | }, |
| 80 | }); |
| 81 | |
| 82 | yield* GitHub.Secret("deploy-key", { |
| 83 | owner: "my-org", |
| 84 | repository: "my-repo", |
| 85 | environment: production, |
| 86 | name: "DEPLOY_KEY", |
| 87 | value: Redacted.make("my-secret-value"), |
| 88 | }); |
| 89 | ``` |
| 90 | |
| 91 | The `environment` prop on secrets and variables accepts either a name |
| 92 | string or the `Environment` resource itself. Reviewers are referenced by |
| 93 | user login and team slug — alchemy resolves them to the IDs GitHub's API |
| 94 | expects. `deploymentBranchPolicy` restricts |
| 95 | which branches can deploy: `{ protectedBranches: true }` or a list of |
| 96 | branch name patterns via `customBranchPolicies`. Environments on private |
| 97 | repositories are plan-gated (Pro/Team/Enterprise); public repositories |
| 98 | get them on every plan. |
| 99 | |
| 100 | ## Create a variable |
| 101 | |
| 102 | `GitHub.Variable` manages a plain-text repository variable — |
| 103 | suitable for non-sensitive configuration like regions, stage labels, |
| 104 | or feature flags. Workflows read it as `${{ vars.NAME }}`: |
| 105 | |
| 106 | ```typescript |
| 107 | yield* GitHub.Variable("aws-region", { |
| 108 | owner: "my-org", |
| 109 | repository: "my-repo", |
| 110 | name: "AWS_REGION", |
| 111 | value: "us-east-1", |
| 112 | }); |
| 113 | ``` |
| 114 | |
| 115 | Unlike secrets, variable values are readable back, so alchemy diffs |
| 116 | the live value and skips the API call when nothing changed. |
| 117 | |
| 118 | Variables take the same `environment` prop as secrets to scope the |
| 119 | variable to one Actions environment instead of the whole repository: |
| 120 | |
| 121 | ```typescript |
| 122 | yield* GitHub.Variable("region", { |
| 123 | owner: "my-org", |
| 124 | repository: "my-repo", |
| 125 | environment: "production", |
| 126 | name: "AWS_REGION", |
| 127 | value: "us-east-1", |
| 128 | }); |
| 129 | ``` |
| 130 | |
| 131 | ## Wire in outputs from other resources |
| 132 | |
| 133 | The values don't have to be literals — pass output attributes from |
| 134 | other resources and alchemy resolves them at deploy time: |
| 135 | |
| 136 | ```typescript |
| 137 | const role = yield* AWS.IAM.Role("ci-role", { ... }); |
| 138 | |
| 139 | yield* GitHub.Secret("ci-role-arn", { |
| 140 | owner: "my-org", |
| 141 | repository: "my-repo", |
| 142 | name: "AWS_ROLE_ARN", |
| 143 | value: Redacted.make(role.roleArn), |
| 144 | }); |
| 145 | ``` |
| 146 | |
| 147 | This is the whole point: the credential and the secret that stores |
| 148 | it live in the same deploy, so rotating one rotates the other. |
| 149 | |
| 150 | ## Bulk-seed with Secrets and Variables |
| 151 | |
| 152 | When you have more than a couple of values, `GitHub.Secrets` and |
| 153 | `GitHub.Variables` fan a map out into individual resources — the map |
| 154 | key becomes both the logical id and the name: |
| 155 | |
| 156 | ```typescript |
| 157 | yield* GitHub.Secrets({ |
| 158 | owner: "my-org", |
| 159 | repository: "my-repo", |
| 160 | secrets: { |
| 161 | AXIOM_INGEST_TOKEN: tokenValue, |
| 162 | AXIOM_DATASET_TRACES: traces.name, |
| 163 | }, |
| 164 | }); |
| 165 | |
| 166 | yield* GitHub.Variables({ |
| 167 | owner: "my-org", |
| 168 | repository: "my-repo", |
| 169 | variables: { |
| 170 | AWS_ROLE_ARN: role.roleArn, |
| 171 | AWS_REGION: region, |
| 172 | }, |
| 173 | }); |
| 174 | ``` |
| 175 | |
| 176 | `Secrets` accepts plain strings, `Redacted` values, or unresolved |
| 177 | inputs (outputs of other resources, `Config` values) — plain strings |
| 178 | are wrapped with `Redacted.make` automatically. Both helpers take an |
| 179 | `environment` to scope every entry in the map to one Actions |
| 180 | environment. |
| 181 | |
| 182 | ## Provision CI credentials as code |
| 183 | |
| 184 | The canonical use is a small, one-shot stack that mints a scoped |
| 185 | cloud credential and writes it straight into your repo — the raw |
| 186 | token never touches your terminal or the GitHub UI: |
| 187 | |
| 188 | ```typescript |
| 189 | // stacks/github.ts |
| 190 | import * as Alchemy from "alchemy"; |
| 191 | import * as Cloudflare from "alchemy/Cloudflare"; |
| 192 | import * as GitHub from "alchemy/GitHub"; |
| 193 | import * as Effect from "effect/Effect"; |
| 194 | import * as Layer from "effect/Layer"; |
| 195 | |
| 196 | export default Alchemy.Stack( |
| 197 | "github", |
| 198 | { |
| 199 | providers: Layer.mergeAll( |
| 200 | Cloudflare.providers(), |
| 201 | GitHub.providers(), |
| 202 | ), |
| 203 | state: Cloudflare.state(), |
| 204 | }, |
| 205 | Effect.gen(function* () { |
| 206 | const apiToken = yield* Cloudflare.ApiToken.AccountApiToken("CIToken", { |
| 207 | // scoped permission policies ... |
| 208 | }); |
| 209 | |
| 210 | yield* GitHub.Secret("cf-api-token", { |
| 211 | owner: "your-org", |
| 212 | repository: "your-repo", |
| 213 | name: "CLOUDFLARE_API_TOKEN", |
| 214 | value: apiToken.value, |
| 215 | }); |
| 216 | }), |
| 217 | ); |
| 218 | ``` |
| 219 | |
| 220 | Deploy it once from your laptop under an elevated profile: |
| 221 | |
| 222 | ```sh |
| 223 | alchemy deploy --config stacks/github.ts --profile admin |
| 224 | ``` |
| 225 | |
| 226 | Re-run it only when you want to rotate the credential or change its |
| 227 | permissions. [Part 5: CI/CD](/cloudflare/tutorial/part-5) walks |
| 228 | through the full Cloudflare version step by step; |
| 229 | [Continuous Integration](/environments/ci) covers the AWS equivalents |
| 230 | (OIDC role ARNs as variables, access keys as secrets). |
| 231 | |
| 232 | ## Token scope |
| 233 | |
| 234 | Writing secrets and variables uses the GitHub credential from |
| 235 | `GitHub.providers()` — env vars, a stored PAT, the `gh` CLI, or |
| 236 | OAuth (see [Setup](/github/setup)). The token needs the `repo` scope |
| 237 | for private repositories, or `public_repo` for public ones. |
| 238 | |
| 239 | ## Where next |
| 240 | |
| 241 | - [Continuous Integration](/environments/ci) — the full GitHub Actions |
| 242 | setup: deploy workflow, PR previews, and provider credentials. |
| 243 | - [Part 5: CI/CD (Cloudflare)](/cloudflare/tutorial/part-5) — mint a |
| 244 | scoped API token and push it into your repo, step by step. |
| 245 | - [Part 5: CI/CD (AWS)](/aws/tutorial/part-5) — the OIDC-based AWS |
| 246 | version. |
| 247 | - [Repository](/github/repository) — manage the repo itself as a |
| 248 | resource. |
| 249 | |
| 250 | Reference: |
| 251 | |
| 252 | - [Environment](/providers/github/environment) · |
| 253 | [Secret](/providers/github/secret) · |
| 254 | [Secrets](/providers/github/secrets) · |
| 255 | [Variable](/providers/github/variable) · |
| 256 | [Variables](/providers/github/variables) |