Skip to content

Continuous Integration

This guide walks you through wiring up GitHub Actions for an Alchemy project: a deploy workflow with PR previews, a stacks/github.ts that mints scoped CI credentials and pushes them into your repo, and provider-specific recipes for Cloudflare and AWS.

The unifying idea is credentials as code. Rather than copy-paste API keys into the GitHub UI, you let Alchemy provision exactly the credentials your CI needs — a scoped Cloudflare API token, an AWS IAM role for OIDC, etc. — and write them straight into the repo as encrypted secrets.

Open a PR. Alchemy does the rest.

  1. A pull request is opened. GitHub fires pull_request. The workflow computes STAGE = pr-{number} and runs the deploy job.
  2. Alchemy deploys the stack to that stage. An isolated copy of every resource — Workers, Lambdas, queues, tables. Stage state lives separately, so PRs can’t touch each other or prod.
  3. A bot comment is posted (or updated) on the PR. The GitHub.Comment resource keeps its logical id stable, so the comment updates in place on each push.
  4. PR is merged or closed → cleanup runs. A second job runs alchemy destroy --stage pr-{n}. Resources gone. Costs gone.

By the end you’ll have:

  1. A GitHub Actions workflow (.github/workflows/deploy.yml) with PR previews, prod deploys, and automatic cleanup.
  2. A stacks/github.ts that creates provider credentials and writes them to your repo as GitHub.Secret / GitHub.Variable.
  3. A PR-comment resource in your alchemy.run.ts that posts the preview URL on each push.
  1. Add preview comments to your stack

    Update your alchemy.run.ts to post a preview URL on each pull request. The comment auto-updates on every push because the logical ID stays the same.

    import * as
    import Alchemy
    Alchemy
    from "alchemy";
    import * as
    import GitHub
    GitHub
    from "alchemy/GitHub";
    import * as
    import Output
    Output
    from "alchemy/Output";
    import * as
    import Effect
    Effect
    from "effect/Effect";
    export default
    import Alchemy
    Alchemy
    .
    Stack<{
    url: any;
    }, unknown>(stackName: string, options: Alchemy.StackProps<unknown>, eff: Effect.Effect<{
    url: any;
    }, never, unknown>): Effect.Effect<Alchemy.CompiledStack<{
    url: any;
    }, any>, never, never> (+2 overloads)
    export Stack
    Stack
    (
    "my-app",
    {
    StackProps<unknown>.providers: Layer<unknown, never, Alchemy.StackServices>
    providers
    : /* ... */ },
    import Effect
    Effect
    .
    const gen: <any, {
    url: any;
    }>(f: () => Generator<any, {
    url: any;
    }, never>) => Effect.Effect<{
    url: any;
    }, unknown, unknown> (+1 overload)

    Provides a way to write effectful code using generator functions, simplifying control flow and error handling.

    When to Use

    gen allows you to write code that looks and behaves like synchronous code, but it can handle asynchronous tasks, errors, and complex control flow (like loops and conditions). It helps make asynchronous code more readable and easier to manage.

    The generator functions work similarly to async/await but with more explicit control over the execution of effects. You can yield* values from effects and return the final result at the end.

    @example

    import { Data, Effect } from "effect"
    class DiscountRateError extends Data.TaggedError("DiscountRateError")<{}> {}
    const addServiceCharge = (amount: number) => amount + 1
    const applyDiscount = (
    total: number,
    discountRate: number
    ): Effect.Effect<number, DiscountRateError> =>
    discountRate === 0
    ? Effect.fail(new DiscountRateError())
    : Effect.succeed(total - (total * discountRate) / 100)
    const fetchTransactionAmount = Effect.promise(() => Promise.resolve(100))
    const fetchDiscountRate = Effect.promise(() => Promise.resolve(5))
    export const program = Effect.gen(function*() {
    const transactionAmount = yield* fetchTransactionAmount
    const discountRate = yield* fetchDiscountRate
    const discountedAmount = yield* applyDiscount(
    transactionAmount,
    discountRate
    )
    const finalAmount = addServiceCharge(discountedAmount)
    return `Final amount to charge: ${finalAmount}`
    })

    @since2.0.0

    @categoryCreating Effects

    gen
    (function* () {
    const
    const app: any
    app
    = yield*
    any
    App
    ;
    if (
    var process: NodeJS.Process
    process
    .
    NodeJS.Process.env: NodeJS.ProcessEnv

    The process.env property returns an object containing the user environment. See environ(7).

    An example of this object looks like:

    {
    TERM: 'xterm-256color',
    SHELL: '/usr/local/bin/bash',
    USER: 'maciej',
    PATH: '~/.bin/:/usr/bin:/bin:/usr/sbin:/sbin:/usr/local/bin',
    PWD: '/Users/maciej',
    EDITOR: 'vim',
    SHLVL: '1',
    HOME: '/Users/maciej',
    LOGNAME: 'maciej',
    _: '/usr/local/bin/node'
    }

    It is possible to modify this object, but such modifications will not be reflected outside the Node.js process, or (unless explicitly requested) to other Worker threads. In other words, the following example would not work:

    Terminal window
    node -e 'process.env.foo = "bar"' &#x26;&#x26; echo $foo

    While the following will:

    import { env } from 'node:process';
    env.foo = 'bar';
    console.log(env.foo);

    Assigning a property on process.env will implicitly convert the value to a string. This behavior is deprecated. Future versions of Node.js may throw an error when the value is not a string, number, or boolean.

    import { env } from 'node:process';
    env.test = null;
    console.log(env.test);
    // => 'null'
    env.test = undefined;
    console.log(env.test);
    // => 'undefined'

    Use delete to delete a property from process.env.

    import { env } from 'node:process';
    env.TEST = 1;
    delete env.TEST;
    console.log(env.TEST);
    // => undefined

    On Windows operating systems, environment variables are case-insensitive.

    import { env } from 'node:process';
    env.TEST = 1;
    console.log(env.test);
    // => 1

    Unless explicitly specified when creating a Worker instance, each Worker thread has its own copy of process.env, based on its parent thread's process.env, or whatever was specified as the env option to the Worker constructor. Changes to process.env will not be visible across Worker threads, and only the main thread can make changes that are visible to the operating system or to native add-ons. On Windows, a copy of process.env on a Worker instance operates in a case-sensitive manner unlike the main thread.

    @sincev0.1.27

    env
    .
    string | undefined
    PULL_REQUEST
    ) {
    yield*
    import GitHub
    GitHub
    .
    const Comment: (id: string, props: {
    owner: Alchemy.Input<string>;
    repository: Alchemy.Input<string>;
    issueNumber: Alchemy.Input<number>;
    body: Alchemy.Input<string>;
    allowDelete?: Alchemy.Input<boolean | undefined>;
    token?: Alchemy.Input<string | undefined>;
    }) => Effect.Effect<GitHub.Comment, never, GitHub.Providers> (+2 overloads)
    Comment
    ("preview-comment", {
    owner: Alchemy.Input<string>

    Repository owner (user or organization).

    owner
    : "your-org",
    repository: Alchemy.Input<string>

    Repository name.

    repository
    : "your-repo",
    issueNumber: Alchemy.Input<number>

    Issue or Pull Request number to comment on.

    issueNumber
    :
    var Number: NumberConstructor
    (value?: any) => number

    An object that represents a number of any kind. All JavaScript numbers are 64-bit floating-point numbers.

    Number
    (
    var process: NodeJS.Process
    process
    .
    NodeJS.Process.env: NodeJS.ProcessEnv

    The process.env property returns an object containing the user environment. See environ(7).

    An example of this object looks like:

    {
    TERM: 'xterm-256color',
    SHELL: '/usr/local/bin/bash',
    USER: 'maciej',
    PATH: '~/.bin/:/usr/bin:/bin:/usr/sbin:/sbin:/usr/local/bin',
    PWD: '/Users/maciej',
    EDITOR: 'vim',
    SHLVL: '1',
    HOME: '/Users/maciej',
    LOGNAME: 'maciej',
    _: '/usr/local/bin/node'
    }

    It is possible to modify this object, but such modifications will not be reflected outside the Node.js process, or (unless explicitly requested) to other Worker threads. In other words, the following example would not work:

    Terminal window
    node -e 'process.env.foo = "bar"' &#x26;&#x26; echo $foo

    While the following will:

    import { env } from 'node:process';
    env.foo = 'bar';
    console.log(env.foo);

    Assigning a property on process.env will implicitly convert the value to a string. This behavior is deprecated. Future versions of Node.js may throw an error when the value is not a string, number, or boolean.

    import { env } from 'node:process';
    env.test = null;
    console.log(env.test);
    // => 'null'
    env.test = undefined;
    console.log(env.test);
    // => 'undefined'

    Use delete to delete a property from process.env.

    import { env } from 'node:process';
    env.TEST = 1;
    delete env.TEST;
    console.log(env.TEST);
    // => undefined

    On Windows operating systems, environment variables are case-insensitive.

    import { env } from 'node:process';
    env.TEST = 1;
    console.log(env.test);
    // => 1

    Unless explicitly specified when creating a Worker instance, each Worker thread has its own copy of process.env, based on its parent thread's process.env, or whatever was specified as the env option to the Worker constructor. Changes to process.env will not be visible across Worker threads, and only the main thread can make changes that are visible to the operating system or to native add-ons. On Windows, a copy of process.env on a Worker instance operates in a case-sensitive manner unlike the main thread.

    @sincev0.1.27

    env
    .
    string
    PULL_REQUEST
    ),
    body: Alchemy.Input<string>

    Comment body (supports GitHub Markdown).

    The body is automatically dedented, so you can use indented template literals without worrying about leading whitespace. Accepts Output<string> at the call site via Output.interpolate to embed resource attributes that are not yet resolved.

    body
    :
    import Output
    Output
    .
    const interpolate: <Args extends any[]>(template: TemplateStringsArray, ...args: Args) => Output.All<Args> extends Alchemy.Output<any, infer Req> ? Alchemy.Output<string, Req> : never
    interpolate
    `
    ## Preview Deployed
    **URL:** ${
    const app: any
    app
    .
    any
    url
    }
    Built from commit ${
    var process: NodeJS.Process
    process
    .
    NodeJS.Process.env: NodeJS.ProcessEnv

    The process.env property returns an object containing the user environment. See environ(7).

    An example of this object looks like:

    {
    TERM: 'xterm-256color',
    SHELL: '/usr/local/bin/bash',
    USER: 'maciej',
    PATH: '~/.bin/:/usr/bin:/bin:/usr/sbin:/sbin:/usr/local/bin',
    PWD: '/Users/maciej',
    EDITOR: 'vim',
    SHLVL: '1',
    HOME: '/Users/maciej',
    LOGNAME: 'maciej',
    _: '/usr/local/bin/node'
    }

    It is possible to modify this object, but such modifications will not be reflected outside the Node.js process, or (unless explicitly requested) to other Worker threads. In other words, the following example would not work:

    Terminal window
    node -e 'process.env.foo = "bar"' &#x26;&#x26; echo $foo

    While the following will:

    import { env } from 'node:process';
    env.foo = 'bar';
    console.log(env.foo);

    Assigning a property on process.env will implicitly convert the value to a string. This behavior is deprecated. Future versions of Node.js may throw an error when the value is not a string, number, or boolean.

    import { env } from 'node:process';
    env.test = null;
    console.log(env.test);
    // => 'null'
    env.test = undefined;
    console.log(env.test);
    // => 'undefined'

    Use delete to delete a property from process.env.

    import { env } from 'node:process';
    env.TEST = 1;
    delete env.TEST;
    console.log(env.TEST);
    // => undefined

    On Windows operating systems, environment variables are case-insensitive.

    import { env } from 'node:process';
    env.TEST = 1;
    console.log(env.test);
    // => 1

    Unless explicitly specified when creating a Worker instance, each Worker thread has its own copy of process.env, based on its parent thread's process.env, or whatever was specified as the env option to the Worker constructor. Changes to process.env will not be visible across Worker threads, and only the main thread can make changes that are visible to the operating system or to native add-ons. On Windows, a copy of process.env on a Worker instance operates in a case-sensitive manner unlike the main thread.

    @sincev0.1.27

    env
    .
    string | undefined
    GITHUB_SHA
    ?.
    String.slice(start?: number, end?: number): string

    Returns a section of a string.

    @paramstart The index to the beginning of the specified portion of stringObj.

    @paramend The index to the end of the specified portion of stringObj. The substring includes the characters up to, but not including, the character indicated by end. If this value is not specified, the substring continues to the end of stringObj.

    slice
    (0, 7)}
    ---
    _This comment updates automatically with each push._
    `,
    });
    }
    return {
    url: any
    url
    :
    const app: any
    app
    .
    any
    url
    };
    }),
    );
  2. Create the base deployment workflow

    Create .github/workflows/deploy.yml. This workflow deploys prod on pushes to main and a pr-<number> stage for each pull request. When a PR is closed the preview environment is destroyed automatically.

    name: Deploy Application
    on:
    push:
    branches:
    - main
    pull_request:
    types:
    - opened
    - reopened
    - synchronize
    - closed
    concurrency:
    group: deploy-${{ github.ref }}
    cancel-in-progress: false
    env:
    STAGE: ${{ github.event_name == 'pull_request' && format('pr-{0}',
    github.event.number) || (github.ref == 'refs/heads/main' && 'prod' ||
    github.ref_name) }}
    jobs:
    deploy:
    if: ${{ github.event.action != 'closed' }}
    runs-on: ubuntu-latest
    permissions:
    contents: read
    pull-requests: write
    steps:
    - uses: actions/checkout@v4
    - name: Setup Bun
    uses: oven-sh/setup-bun@v2
    - name: Install dependencies
    run: bun install
    - name: Deploy
    run: bun alchemy deploy --stage ${{ env.STAGE }}
    env:
    PULL_REQUEST: ${{ github.event.number }}
    GITHUB_SHA: ${{ github.sha }}
    GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
    cleanup:
    runs-on: ubuntu-latest
    if: ${{ github.event_name == 'pull_request' && github.event.action == 'closed'
    }}
    permissions:
    contents: read
    pull-requests: write
    steps:
    - uses: actions/checkout@v4
    - name: Setup Bun
    uses: oven-sh/setup-bun@v2
    - name: Install dependencies
    run: bun install
    - name: Safety Check
    run: |-
    if [ "${{ env.STAGE }}" = "prod" ]; then
    echo "ERROR: Cannot destroy prod environment in cleanup job"
    exit 1
    fi
    - name: Destroy Preview Environment
    run: bun alchemy destroy --stage ${{ env.STAGE }}
    env:
    PULL_REQUEST: ${{ github.event.number }}
  3. Add provider credentials

    The base workflow above doesn’t include any provider credentials yet. Pick the section below that matches your provider and apply the changes to your workflow.

    GITHUB_TOKEN is provided automatically by GitHub Actions and is used by the GitHub.Comment resource to post PR comments.

Before CI can deploy, your repo needs provider credentials configured as Actions secrets and variables. We recommend managing this with a dedicated stacks/github.ts that you deploy once locally — that way the credential set is reviewable, diffable, and reproducible.

The stack uses GitHub.Secret and GitHub.Variable to write into your repository, and provider resources like Cloudflare.AccountApiToken or AWS.IAM.Role to mint the credentials themselves.

The stacks/github.ts you’re about to write needs more privilege than your day-to-day app stack. Creating a Cloudflare API token requires API Tokens > Write; creating an IAM role and OIDC provider requires IAM admin rights.

Rather than hand those rights to your default profile, create a separate admin profile:

Terminal window
alchemy login --profile admin

After creating the file, deploy it once locally under the admin profile:

Terminal window
alchemy deploy stacks/github.ts --profile admin

You only need to re-run this stack when you want to rotate credentials or change permissions.

The simplest case: a single Cloudflare account, used for both prod and PR previews. Your admin profile mints a scoped API token, and the stack pushes it into your repo as a GitHub Actions secret.

stacks/github.ts
import * as Alchemy from "alchemy";
import * as Cloudflare from "alchemy/Cloudflare";
import * as GitHub from "alchemy/GitHub";
import * as Config from "effect/Config";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import * as Redacted from "effect/Redacted";
export default Alchemy.Stack(
"github",
{
providers: Layer.mergeAll(
Cloudflare.providers(),
GitHub.providers(),
),
state: Cloudflare.state(),
},
Effect.gen(function* () {
const accountId = yield* Config.string("CLOUDFLARE_ACCOUNT_ID");
const apiToken = yield* Cloudflare.AccountApiToken("CIToken", {
accountId,
policies: [
{
effect: "allow",
permissionGroups: [
"Workers Scripts Write",
"Workers KV Storage Write",
"Workers R2 Storage Write",
"D1 Write",
"Queues Write",
"Pages Write",
"Account Settings Write",
"Secrets Store Write",
"Workers Tail Read",
],
resources: {
[`com.cloudflare.api.account.${accountId}`]: "*",
},
},
],
});
yield* GitHub.Secret("cf-api-token", {
owner: "your-org",
repository: "your-repo",
name: "CLOUDFLARE_API_TOKEN",
value: apiToken.value,
});
yield* GitHub.Secret("cf-account-id", {
owner: "your-org",
repository: "your-repo",
name: "CLOUDFLARE_ACCOUNT_ID",
value: Redacted.make(accountId),
});
}),
);

A few details worth knowing:

  • Cloudflare.AccountApiToken calls POST /accounts/{account_id}/tokens and Cloudflare returns the freshly minted token value exactly once. Alchemy captures it, stores it in state, and pipes it straight into GitHub.Secret — the raw token never appears in your terminal or in CI logs.
  • Trim the permissionGroups list to just what your app needs. Listed above is a sensible default for a Workers + KV + R2 + D1 app.
  • The resource diff is stable across redeploys. Editing a policy produces a clean update against the same token ID.

Then add the secrets to your workflow’s deploy and cleanup steps:

- name: Deploy
run: bun alchemy deploy --stage ${{ env.STAGE }}
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
PULL_REQUEST: ${{ github.event.number }}
GITHUB_SHA: ${{ github.sha }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Destroy Preview Environment
run: bun alchemy destroy --stage ${{ env.STAGE }}
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
PULL_REQUEST: ${{ github.event.number }}

For stricter isolation you can run preview environments on one Cloudflare account and production on another. Mint two tokens from the same stacks/github.ts and prefix the secrets so the workflow can pick the right pair based on STAGE.

stacks/github.ts
import * as Alchemy from "alchemy";
import * as Cloudflare from "alchemy/Cloudflare";
import * as GitHub from "alchemy/GitHub";
import * as Config from "effect/Config";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import * as Redacted from "effect/Redacted";
export default Alchemy.Stack(
"github",
{
providers: Layer.mergeAll(
Cloudflare.providers(),
GitHub.providers(),
),
state: Cloudflare.state(),
},
Effect.gen(function* () {
const testAccountId = yield* Config.string("TEST_CLOUDFLARE_ACCOUNT_ID");
const prodAccountId = yield* Config.string("PROD_CLOUDFLARE_ACCOUNT_ID");
const policies = (accountId: string) => [
{
effect: "allow" as const,
permissionGroups: [
"Workers Scripts Write",
"Workers KV Storage Write",
"Workers R2 Storage Write",
"D1 Write",
"Queues Write",
"Pages Write",
"Account Settings Write",
"Secrets Store Write",
"Workers Tail Read",
],
resources: { [`com.cloudflare.api.account.${accountId}`]: "*" },
},
];
const testToken = yield* Cloudflare.AccountApiToken("TestApiToken", {
accountId: testAccountId,
policies: policies(testAccountId),
});
const prodToken = yield* Cloudflare.AccountApiToken("ProdApiToken", {
accountId: prodAccountId,
policies: policies(prodAccountId),
});
const secrets: Record<string, Redacted.Redacted<string>> = {
TEST_CLOUDFLARE_API_TOKEN: testToken.value,
TEST_CLOUDFLARE_ACCOUNT_ID: Redacted.make(testAccountId),
PROD_CLOUDFLARE_API_TOKEN: prodToken.value,
PROD_CLOUDFLARE_ACCOUNT_ID: Redacted.make(prodAccountId),
};
yield* Effect.all(
Object.entries(secrets).map(([name, value]) =>
GitHub.Secret(name, {
owner: "your-org",
repository: "your-repo",
name,
value,
}),
),
);
}),
);

Your admin profile needs API-token-write permission on both accounts. The simplest setup is to log in with the Global API Key of a user that’s a member of both.

In your workflow, switch on STAGE to choose the right credential pair:

- name: Deploy
run: bun alchemy deploy --stage ${{ env.STAGE }}
env:
CLOUDFLARE_API_TOKEN: ${{ env.STAGE == 'prod' && secrets.PROD_CLOUDFLARE_API_TOKEN || secrets.TEST_CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ env.STAGE == 'prod' && secrets.PROD_CLOUDFLARE_ACCOUNT_ID || secrets.TEST_CLOUDFLARE_ACCOUNT_ID }}
PULL_REQUEST: ${{ github.event.number }}
GITHUB_SHA: ${{ github.sha }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

GitHub OIDC lets your workflow assume an IAM role without storing long-lived access keys. The GitHub stack creates the OIDC provider and an IAM role scoped to your repo, then writes the role ARN and region to the repo as Actions variables (not secrets — they’re not sensitive).

Your --profile admin AWS credentials need IAM admin rights for this stack: it creates an OpenIDConnectProvider and an IAM Role. Run alchemy login --profile admin and choose a credential (SSO/OIDC, access keys, etc.) that’s authorized for IAM administration.

stacks/github.ts
import * as Alchemy from "alchemy";
import * as AWS from "alchemy/AWS";
import * as GitHub from "alchemy/GitHub";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
export default Alchemy.Stack(
"github",
{
providers: Layer.mergeAll(
AWS.providers(),
GitHub.providers(),
),
},
Effect.gen(function* () {
const oidc = yield* AWS.IAM.OpenIDConnectProvider("GitHubOidc", {
url: "https://token.actions.githubusercontent.com",
clientIDList: ["sts.amazonaws.com"],
});
const role = yield* AWS.IAM.Role("GitHubActionsRole", {
roleName: "github-actions",
assumeRolePolicyDocument: {
Version: "2012-10-17",
Statement: [
{
Effect: "Allow",
Principal: {
Federated: oidc.openIDConnectProviderArn,
},
Action: "sts:AssumeRoleWithWebIdentity",
Condition: {
StringEquals: {
"token.actions.githubusercontent.com:aud":
"sts.amazonaws.com",
},
StringLike: {
"token.actions.githubusercontent.com:sub":
"repo:your-org/your-repo:*",
},
},
},
],
},
managedPolicyArns: [
"arn:aws:iam::aws:policy/AdministratorAccess",
],
});
const region = yield* AWS.Region;
yield* GitHub.Variable("aws-role-arn", {
owner: "your-org",
repository: "your-repo",
name: "AWS_ROLE_ARN",
value: role.roleArn,
});
yield* GitHub.Variable("aws-region", {
owner: "your-org",
repository: "your-repo",
name: "AWS_REGION",
value: region,
});
}),
);

After deploying, update the workflow to add id-token: write and the configure-aws-credentials step. No secrets needed:

deploy:
permissions:
id-token: write
contents: read
pull-requests: write
steps:
- uses: actions/checkout@v4
# ...setup and install...
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ vars.AWS_ROLE_ARN }}
aws-region: ${{ vars.AWS_REGION }}
- name: Deploy
run: bun alchemy deploy --stage ${{ env.STAGE }}
cleanup:
permissions:
id-token: write
contents: read
pull-requests: write
steps:
- uses: actions/checkout@v4
# ...setup, install, and safety check...
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ vars.AWS_ROLE_ARN }}
aws-region: ${{ vars.AWS_REGION }}
- name: Destroy Preview Environment
run: bun alchemy destroy --stage ${{ env.STAGE }}

If OIDC isn’t an option (some sandbox accounts disallow it, or you’re targeting an account you don’t fully control), fall back to static IAM access keys stored as repo secrets. The stack pushes existing keys from your environment into the repo — it does not mint a new IAM user, since most teams prefer to do that step in the AWS console.

stacks/github.ts
import * as Alchemy from "alchemy";
import * as GitHub from "alchemy/GitHub";
import * as Config from "effect/Config";
import * as Effect from "effect/Effect";
import * as Redacted from "effect/Redacted";
export default Alchemy.Stack(
"github",
{
providers: GitHub.providers(),
},
Effect.gen(function* () {
const accessKeyId = yield* Config.string("AWS_ACCESS_KEY_ID");
const secretAccessKey = yield* Config.redacted("AWS_SECRET_ACCESS_KEY");
yield* GitHub.Secret("aws-access-key-id", {
owner: "your-org",
repository: "your-repo",
name: "AWS_ACCESS_KEY_ID",
value: Redacted.make(accessKeyId),
});
yield* GitHub.Secret("aws-secret-access-key", {
owner: "your-org",
repository: "your-repo",
name: "AWS_SECRET_ACCESS_KEY",
value: secretAccessKey,
});
yield* GitHub.Variable("aws-region", {
owner: "your-org",
repository: "your-repo",
name: "AWS_REGION",
value: "us-east-1",
});
}),
);

Then add the secrets to your workflow’s deploy and cleanup steps:

- name: Deploy
run: bun alchemy deploy --stage ${{ env.STAGE }}
env:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
AWS_REGION: ${{ vars.AWS_REGION || 'us-east-1' }}
PULL_REQUEST: ${{ github.event.number }}
GITHUB_SHA: ${{ github.sha }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Destroy Preview Environment
run: bun alchemy destroy --stage ${{ env.STAGE }}
env:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
AWS_REGION: ${{ vars.AWS_REGION || 'us-east-1' }}
PULL_REQUEST: ${{ github.event.number }}