Skip to content
CodeFloe

PR Preview Environments

Every documentation pull request on CodeFloe gets an ephemeral preview so reviewers can see the rendered result before merging. You can add the same PR preview environments to your own CodeFloe repositories.

CodeFloe runs a shared, hosted roost daemon, so there is nothing to install or operate. roost gives every pull request a URL like pr-123.preview.codefloe.com, backed by either a static site or a running container, and tears the preview down when the pull request closes. You only add a preview step to your CI pipeline; CodeFloe provides the daemon and the credentials.

Your CI pipeline runs the roost client on pull_request to deploy a preview and on pull_request_closed to destroy it. roost derives a collision-proof preview name from the repository and pull request number, so PR #1 in acme/app and PR #1 in acme/other never clash on the shared daemon. The daemon owns TLS, provisioning, and cleanup, so a missed teardown is reclaimed automatically once the preview’s TTL passes.

roost supports two kinds of preview, and both share the same deploy/destroy contract, so your CI never has to care which one is in play:

  • Static serves a pre-built directory of files directly. This is the simplest path and fits any site generator that emits a folder of files.
  • Container runs a container from an image and reverse-proxies to it. Use this when the app has to actually run, for example server-side rendering (SSR) or a backend API.

Crow CI is the integrated path on CodeFloe. The roost plugin wraps the client into a single step that covers the whole pull request lifecycle: it deploys on pull_request and destroys when the pull request is closed or merged. Crow keeps merge and close-without-merge as distinct events, so the teardown listens to both pull_request_closed and pull_request_merged.

You do not manage any credentials. roost_server and roost_token are provided as CodeFloe global CI secrets bound to the roost plugin, so your pipeline only references them with from_secret and the values are never exposed.

when:
  - event: [pull_request, pull_request_closed, pull_request_merged]

steps:
  build:
    image: node:22
    commands:
      - npm ci
      # emits ./dist
      - npm run build
    when:
      - event: pull_request

  preview:
    image: codefloe.com/crow-plugins/roost:0.7.0
    settings:
      server:
        from_secret: roost_server
      token:
        from_secret: roost_token
      dir: ./dist
      ttl: 168h
    when:
      - event: [pull_request, pull_request_closed, pull_request_merged]

The plugin runs roost deploy on pull_request and roost destroy on pull_request_closed or pull_request_merged, recomputing the same name from the repository and pull request number.

To have the plugin post the preview link as a pull request comment, add a comment_token set to a forge API token with issue write scope:

      comment_token:
        from_secret: roost_comment_token

The comment carries a hidden marker, so later deploys update the same comment in place rather than adding new ones, and on teardown it is struck through rather than deleted (roost >= 0.7.0).

Build the app into a container image, push it to the CodeFloe registry, then swap dir for image and port to preview the running container:

  preview:
    image: codefloe.com/crow-plugins/roost:0.7.0
    settings:
      server:
        from_secret: roost_server
      token:
        from_secret: roost_token
      image: codefloe.com/acme/app:pr-${CI_COMMIT_PULL_REQUEST}
      port: 4321
      ttl: 168h
    when:
      - event: [pull_request, pull_request_closed, pull_request_merged]

The app inside the container must bind 0.0.0.0:<port>, not 127.0.0.1:<port>, or the reverse proxy cannot reach it.

Forgejo Actions has no dedicated roost action, so you call the roost client directly. Reference the ROOST_SERVER and ROOST_TOKEN secrets in the deploy and destroy steps.

The published roost image is a bare Alpine binary with no Node, so it cannot run JavaScript actions such as actions/checkout. The deploy job therefore runs in a Node image and fetches the roost binary, while the destroy job needs no repository contents and can use the small roost image with a plain run step.

# .forgejo/workflows/preview.yml
name: preview

on:
  pull_request:
    types: [opened, synchronize, reopened, closed]

env:
  ROOST_VERSION: v0.5.0

jobs:
  deploy:
    if: ${{ github.event.action != 'closed' }}
    runs-on: docker
    container:
      image: node:22
    steps:
      - uses: actions/checkout@v4
      - run: npm ci
      # emits ./dist
      - run: npm run build
      - name: install roost client
        run: |
          curl -fsSLo /usr/local/bin/roost \
            "https://codefloe.com/pat-s/roost/releases/download/${ROOST_VERSION}/roost-${ROOST_VERSION}-linux-amd64"
          chmod +x /usr/local/bin/roost
      - name: preview-deploy
        env:
          ROOST_SERVER: ${{ secrets.ROOST_SERVER }}
          ROOST_TOKEN: ${{ secrets.ROOST_TOKEN }}
        run: |
          roost deploy --repo "${{ github.repository }}" --pr "${{ github.event.number }}" --dir ./dist --ttl 168h

  destroy:
    if: ${{ github.event.action == 'closed' }}
    runs-on: docker
    container:
      image: codefloe.com/pat-s/roost:latest
    steps:
      - name: preview-destroy
        env:
          ROOST_SERVER: ${{ secrets.ROOST_SERVER }}
          ROOST_TOKEN: ${{ secrets.ROOST_TOKEN }}
        run: |
          roost destroy --repo "${{ github.repository }}" --pr "${{ github.event.number }}"

Forgejo also exposes the same context under gitea.*; the github.* names are kept for drop-in compatibility with GitHub Actions.

Swap the deploy command to run the built image instead of static files:

      - name: preview-deploy
        env:
          ROOST_SERVER: ${{ secrets.ROOST_SERVER }}
          ROOST_TOKEN: ${{ secrets.ROOST_TOKEN }}
        run: |
          roost deploy --repo "${{ github.repository }}" --pr "${{ github.event.number }}" --image "codefloe.com/acme/app:pr-${{ github.event.number }}" --port 4321
  • The --ttl is a safety net: even if the destroy step never runs, the daemon’s reaper reclaims any preview past its TTL.
  • Pass --name instead of --repo/--pr if you want to choose the DNS label yourself; it is mutually exclusive with --repo.

Working examples for both CI systems and both preview kinds live in codefloe/roost-preview-examples. See the CI/CD Examples page for more example repositories.