> ## Documentation Index
> Fetch the complete documentation index at: https://docs.overlayed.gg/llms.txt
> Use this file to discover all available pages before exploring further.

# Shipping from GitHub Actions

> Bundle, release and promote your overlay from a GitHub Actions workflow with the Overlayed CLI.

Once your overlay works locally, you want every merge to reach your testers without anyone opening the dashboard, and a
tested build to reach everyone with one click. This guide sets up two workflows: one that bundles your app and site when
you push to `main` and releases them to a private `beta` channel, and one you run by hand to promote what's on `beta`
to `stable`.

<Info>
  This guide uses the [Overlayed CLI](/packages/cli) 2.0 directly; there is no separate GitHub Action. For what bundles,
  releases and channels are, see [Understanding Bundles](/deployment/introduction).
</Info>

## How It Fits Together

1. **`overlayed bundle` uploads your app and site** as bundles, and the platform builds each one. With `--release`, it
   then releases them to a channel once they're built.
2. **A release puts a bundle on a channel**, and builds there: an app release builds your installer, a site release
   deploys your site. When the app and site are released together, the app goes first and the site's minimum app version
   is set to the new app version, so users never get a site that needs an app they don't have yet.
3. **Promoting releases the same bundle on another channel.** `stable` gets exactly the bundle your testers ran on
   `beta`, built again for that channel.
4. **Every command fails the job when something fails.** A build or release that fails, is cancelled or times out exits
   `1`, so a broken release stops the workflow instead of passing silently.

## Set It Up

<Steps>
  <Step title="Create a private beta channel">
    Every application starts with a public `stable` channel. Create a **private** channel named `beta` on the
    [channels page](https://overlay.dev/channels). Your backend decides who may update from it: see
    [Private Channels](/deployment/private-channels).

    Leave **auto-release on bundle** off for `beta`: the workflow releases to it explicitly, and
    `overlayed bundle --release` refuses a channel that already releases every bundle by itself. Check `stable` too:
    with auto-release on there, every bundle this workflow uploads would also go straight to `stable`, skipping
    `beta`.
  </Step>

  <Step title="Create an API key">
    Open [API keys](https://overlay.dev/user-settings/api-keys) in the dashboard and create a key scoped to your
    application, with these permissions:

    | Permission | Why |
    | - | - |
    | `application::asset_bundles::read`, `application::asset_bundles::write` | Upload bundles and follow their builds |
    | `application::releases::read`, `application::releases::create` | Release and promote |
    | `application::releases::update` | Publish, raise rollouts, cancel |
    | `application::channels::read` | Find channels by name |

    <Tip>
      Running `overlayed login` on your machine opens this page with exactly these permissions preselected.
    </Tip>

    <Warning>
      Your role must hold a permission to give it to a key. Developers can't create or update releases, so someone
      whose role can, such as an application owner, needs to create this key.
    </Warning>
  </Step>

  <Step title="Store it in GitHub">
    In your repository, open **Settings → Secrets and variables → Actions** and add:

    * A **secret** named `OVERLAYED_API_KEY`, holding the key.
    * A **variable** named `OVERLAYED_APPLICATION_ID`, holding your application's id (the `applicationId` in your
      `overlayed.config.ts`). It isn't secret.

    The CLI reads both from the environment. The workflows below give the key only to the step that runs `overlayed`:
    `npm ci` and your build run code from your dependencies, which has no business seeing a key that can release.
  </Step>

  <Step title="Release pushes to beta">
    Add this workflow. It builds your project, then bundles the app and site and releases them to `beta`, bumping the
    patch version of the latest `beta` release each time. Runs take turns: while one releases, the newest push waits.
    GitHub keeps only the newest waiting run, which is all `beta` needs, since it releases a `main` that includes the
    pushes before it.

    The checkout keeps no Git credentials (`persist-credentials: false`) and the job's `GITHUB_TOKEN` can only read:
    `npm ci` and your build run your dependencies' code, and nothing here pushes.

    ```yaml .github/workflows/release-beta.yml theme={null}
    name: Release to beta

    on:
      push:
        branches: [main]

    # One release at a time, shared with the promote workflow: two runs bumping the same version would collide, and a
    # promotion mid-release could pair the new app with the old site
    concurrency:
      group: overlayed-release
      cancel-in-progress: false

    permissions:
      contents: read

    jobs:
      release:
        runs-on: ubuntu-latest
        timeout-minutes: 60
        steps:
          - uses: actions/checkout@v4
            with:
              persist-credentials: false

          - uses: actions/setup-node@v4
            with:
              node-version: 24
              cache: npm

          - run: npm ci
          - run: npm run build

          - run: npm install --global @overlayed/cli@2

          - name: Bundle and release to beta
            env:
              OVERLAYED_API_KEY: ${{ secrets.OVERLAYED_API_KEY }}
              OVERLAYED_APPLICATION_ID: ${{ vars.OVERLAYED_APPLICATION_ID }}
            run: |
              overlayed bundle \
                --app "app-${{ github.run_number }}-${{ github.run_attempt }}" \
                --site "site-${{ github.run_number }}-${{ github.run_attempt }}" \
                --release beta \
                --release-version patch
    ```

    * `npm ci` comes before bundling: the CLI reads the installed `@overlayed/app` and `@overlayed/electron` versions
      from `node_modules`, and `npm run build` produces the files your `overlayed.config.ts` includes.
    * Without a terminal the CLI doesn't prompt, so each bundle is named on the command line. The run number and
      attempt keep the names unique, including when you re-run a failed job.
    * The step waits for the builds and both releases, up to 15 minutes each, and fails if any of them fails.
  </Step>

  <Step title="Promote beta to stable on demand">
    Add a second workflow that you run from the **Actions** tab when `beta` looks good. It promotes `beta`'s newest
    published app and site to `stable`, app first.

    ```yaml .github/workflows/promote-stable.yml theme={null}
    name: Promote beta to stable

    on:
      workflow_dispatch:
        inputs:
          version:
            description: "Version on stable: patch, minor, major, or a version like 1.4.0"
            required: true
            default: patch

    # The same group as release-beta.yml: a promotion waits for a beta release in progress
    concurrency:
      group: overlayed-release
      cancel-in-progress: false

    permissions:
      contents: read

    jobs:
      promote:
        runs-on: ubuntu-latest
        timeout-minutes: 45
        # Waits for approval only if the stable environment has required reviewers (see below)
        environment: stable
        steps:
          - uses: actions/setup-node@v4
            with:
              node-version: 24

          - run: npm install --global @overlayed/cli@2

          - name: Promote to stable
            env:
              OVERLAYED_API_KEY: ${{ secrets.OVERLAYED_API_KEY }}
              OVERLAYED_APPLICATION_ID: ${{ vars.OVERLAYED_APPLICATION_ID }}
              VERSION: ${{ inputs.version }}
            run: overlayed releases promote "$VERSION" --from beta --type both --to stable
    ```

    Both workflows share one concurrency group, so a promotion started while `beta` is releasing waits until that
    release finishes rather than promoting halfway through it. Waiting isn't the same as succeeding: if a beta run
    released its app but not its site, `beta`'s newest site is still the previous one, so finish the site on `beta`
    before promoting. GitHub keeps one waiting run per group: if a push
    to `main` arrives while your promotion waits, it takes the promotion's place, and you start the promotion again.

    Promoting needs no checkout and no `overlayed.config.ts`: `OVERLAYED_APPLICATION_ID` tells the CLI which
    application to work on. The version input goes through `env:` rather than straight into the script, so nothing
    typed into the form can run as a command.

    `stable` is public. In a terminal the CLI would ask before releasing there; in a workflow it goes ahead.
  </Step>

  <Step title="Require an approval for stable (optional)">
    `environment: stable` on its own only names a GitHub environment; GitHub creates it unprotected, and the job starts
    at once. To make every promotion wait for a person, open **Settings → Environments**, select (or create) `stable`,
    and add **Required reviewers**. The promote job then waits until one of them approves it.

    The approval gates this workflow, not the key: a key with release permissions can release to any channel of its
    application, including from the beta workflow. Keep the key to these workflows, and review changes to them as you
    would a release.
  </Step>
</Steps>

## Choosing Versions

Every release needs a version, and it must be higher than the channel's latest release of the same type.

<Tabs>
  <Tab title="Bump each time">
    `--release-version patch` (or `minor`, `major`) bumps the channel's latest release of each type, so the app and
    site each count up on their own. Nothing to keep in sync, which suits a channel that gets every merge.
  </Tab>

  <Tab title="From a git tag">
    Release when you push a tag such as `v1.4.0`, using the tag as the version for both:

    ```yaml .github/workflows/release-beta.yml theme={null}
    on:
      push:
        tags: ["v*"]

    # …
          - name: Bundle and release to beta
            env:
              OVERLAYED_API_KEY: ${{ secrets.OVERLAYED_API_KEY }}
              OVERLAYED_APPLICATION_ID: ${{ vars.OVERLAYED_APPLICATION_ID }}
            run: |
              overlayed bundle \
                --app "app-$GITHUB_REF_NAME-$GITHUB_RUN_ATTEMPT" --site "site-$GITHUB_REF_NAME-$GITHUB_RUN_ATTEMPT" \
                --release beta --release-version "${GITHUB_REF_NAME#v}"
    ```
  </Tab>

  <Tab title="Separate app and site versions">
    `--app-release-version` and `--site-release-version` give each its own, e.g. when your site is versioned
    independently:

    ```bash theme={null}
    overlayed bundle --app "app-$GITHUB_RUN_NUMBER-$GITHUB_RUN_ATTEMPT" \
    	--site "site-$GITHUB_RUN_NUMBER-$GITHUB_RUN_ATTEMPT" --release beta \
    	--app-release-version patch --site-release-version 3.2.0
    ```

    `releases promote` takes `--app-version` and `--site-version` the same way.
  </Tab>
</Tabs>

## Rolling Out Gradually

By default a release uses its channel's **default rollout duration** if the channel has one (set it on the channel),
rising from 0% to 100% of users over that time. A channel without one releases to everyone at once. To choose per
release, add a [release option](/packages/cli#release-options):

```bash theme={null}
# 10% of stable users now
overlayed releases promote "$VERSION" --from beta --type both --to stable --rollout 10

# Later, from another run of a workflow: raise it (a published release's rollout only goes up)
overlayed releases rollout <release-id> 50
```

## Using the Results

Add the global `--json` flag and a command prints only its result on stdout, with every id, status and dashboard link.
This writes the promoted releases to the run's summary page:

```yaml theme={null}
      - name: Promote to stable
        env:
          OVERLAYED_API_KEY: ${{ secrets.OVERLAYED_API_KEY }}
          OVERLAYED_APPLICATION_ID: ${{ vars.OVERLAYED_APPLICATION_ID }}
          VERSION: ${{ inputs.version }}
        run: |
          set +e
          overlayed --json releases promote "$VERSION" --from beta --type both --to stable > promote.json
          status=$?
          jq -r '.releases[]? | "- \(.release.bundle.type) \(.release.version): \(.release.dashboard_url)"' promote.json \
            >> "$GITHUB_STEP_SUMMARY"
          exit $status
```

<Tip>
  A failed command still writes its JSON before exiting `1`, so the ids of whatever it did create aren't lost. GitHub
  stops a step at the first failing command, so the script above turns that off (`set +e`) to write the summary
  anyway, then exits with the CLI's code. See [JSON Output](/packages/cli#json-output).
</Tip>

## FAQ

<AccordionGroup>
  <Accordion title="The job fails with 'No terminal to prompt in'">
    A command needed an answer it would normally ask for. For `overlayed bundle`, name each bundle (`--app <name>`,
    `--site <name>`) and say which to bundle; for `overlayed login`, don't run it in CI at all: `OVERLAYED_API_KEY`
    replaces it.
  </Accordion>

  <Accordion title="The release is refused: 'isn't higher than the latest release'">
    Another run released first, or the version you passed is already behind. Use a bump (`patch`) or a higher
    version. The `concurrency` block in the workflows above keeps two runs from racing for the same version.
  </Accordion>

  <Accordion title="The release is refused because the channel is public">
    Your application either doesn't allow releases to public channels, or requires releases there to be drafts for
    review. The error says which. For draft review, add `--draft`; the release then waits as a draft instead of going
    live.
  </Accordion>

  <Accordion title="'Your API key is missing a permission'">
    The error lists the missing permission. Create a key that has it (see the table above) and update the
    `OVERLAYED_API_KEY` secret. API keys also expire: when a working workflow starts failing to authenticate, check the
    key's expiry in the dashboard.
  </Accordion>

  <Accordion title="The app was released but the site wasn't">
    The command fails and names the app release that went out. If the site's release was refused, failed or was
    cancelled, it also prints the `overlayed releases create` command that releases the site on its own, with the
    right minimum app version: fix the cause it reports, then run that command. If the wait gave up while the site was
    still building, the site release exists and holds its version: follow it with `overlayed releases wait <id>`,
    using the site release's id printed above the error, instead.
  </Accordion>

  <Accordion title="How do I pull a bad release?">
    `overlayed releases cancel <release-id>` cancels a release that is still building, or revokes one that is live.
    After a revoke, users fall back to the previous release on the channel.
  </Accordion>

  <Accordion title="Can I release only the site?">
    Yes: bundle with `--site` alone. If the new site needs a newer app, add `--min-app-version` so users on older apps
    keep the old site. See [Safe Site Releases](/deployment/introduction#safe-site-releases).
  </Accordion>

  <Accordion title="My project is in a monorepo">
    The CLI finds `overlayed.config.ts` in the current directory or a parent, or up to three directories below it. Run
    the steps from your app's package (`working-directory:`) if it's deeper than that. See
    [Config File Lookup](/packages/cli#config-file-lookup). If the lockfile isn't at the repository root, also point
    `actions/setup-node` at it with `cache-dependency-path` (e.g. `apps/overlay/package-lock.json`), or its npm cache
    step fails.
  </Accordion>

  <Accordion title="The step gives up while a build is still running">
    The CLI waits 15 minutes for each build or release by default, then fails. The build carries on on the server.
    Set `OVERLAYED_WAIT_TIMEOUT` (in seconds) in `env:` to wait longer. The limit applies to each wait, and they run
    one after another: `bundle --release` waits on the bundle builds, then the app release, then the site release,
    and `promote --type both` on the app, then the site. Keep the job's `timeout-minutes` above all of them together,
    or GitHub can stop the job between the app and the site.
  </Accordion>
</AccordionGroup>

## Next Steps

<CardGroup cols={2}>
  <Card title="CLI Reference" icon="terminal" href="/packages/cli">
    Every command, option, exit code and JSON result.
  </Card>

  <Card title="Understanding Bundles" icon="box" href="/deployment/introduction">
    Bundles, channels and releases, and what each release builds.
  </Card>

  <Card title="Private Channels" icon="lock" href="/deployment/private-channels">
    Give your testers early builds.
  </Card>

  <Card title="App Updates" icon="arrows-rotate" href="/deployment/updates">
    How installed apps pick up a new release.
  </Card>
</CardGroup>


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.