Shared Forgejo Actions composite action: PR previews on debianrb
  • Shell 74%
  • JavaScript 26%
Find a file
2026-10-01 21:35:07 +02:00
action.yml README: block-style concurrency (a flow mapping cannot hold ${{ }}) 2026-10-01 21:33:51 +02:00
comment.mjs The preview action 2026-10-01 21:29:48 +02:00
preview.sh Do not die when there is no older image to prune (grep -v exit 1 under pipefail) 2026-10-01 21:35:07 +02:00
README.md README: block-style concurrency (a flow mapping cannot hold ${{ }}) 2026-10-01 21:33:51 +02:00

preview-action

Pull request previews on debianrb for any repository on this Forgejo: build the PR, run it as a container, get a link in a PR comment. No Vercel, no per-project setup beyond one workflow file.

https://<slot>.preview.rensebakker.nl  ->  container preview-<slot>:3000 on the `edge` network

Use

.forgejo/workflows/preview.yml in the repository:

name: Preview
on:
  pull_request:
    types: [opened, synchronize, reopened, closed]
concurrency:
  group: preview-${{ github.event.number }}
  cancel-in-progress: true
jobs:
  preview:
    runs-on: ubuntu-latest
    timeout-minutes: 20
    steps:
      - uses: actions/checkout@v4
      - uses: https://git.rensebakker.nl/brense/preview-action@main

The URL form of uses: is required (a bare owner/repo resolves against data.forgejo.org), and this repository must stay public for other repositories to fetch it. closed in types is what tears the preview down.

A monorepo runs the action once per app with a matrix:

    strategy:
      matrix:
        include:
          - { name: site, path: apps/rensebakkernl, build: bun run build:site }
          - { name: ui,   path: packages/ui,        build: bun run build:storybook }
    steps:
      - uses: actions/checkout@v4
      - uses: https://git.rensebakker.nl/brense/preview-action@main
        with: { name: '${{ matrix.name }}', path: '${{ matrix.path }}', build: '${{ matrix.build }}' }

What it does

On opened / synchronize / reopened:

  1. install — bun install --frozen-lockfile if there is a bun.lock, npm ci if package-lock.json, else nothing. (/ci-cache/bun is used when the runner provides it.)
  2. build — bun run build / npm run build at the repository root if it has a build script, else in path.
  3. package, first match in path wins:
    • Dockerfile.preview — your image, your rules.
    • Dockerfile — same.
    • .output/server/index.mjs (a Nitro node-server build) — wrapped in node:24-bookworm-slim.
    • a static site in dist, build, storybook-static or out — served by nginx:alpine with SPA fallback.
  4. run as preview-<slot> on edge: --memory 512m --cpus 1, no published ports, PORT=3000 HOST=0.0.0.0 PREVIEW_URL=https://<slot>... in the environment, plus anything from the env input.
  5. wait for it to answer on 3000, then comment the URL (one comment per name, edited on every push).

On closed: remove the container and image, release a custom slot, update the comment.

The container must listen on port 3000. nginx routes every slot to :3000 and cannot read per-container settings. Node servers that honour PORT and the generated images already do; a Dockerfile.preview must.

Slots

hostname assigned lifetime
pool s1..s8 lowest free; when full, the least recently updated pool preview is evicted until the PR closes, is evicted, or goes 7 days without a push
custom any [a-z0-9]{1,24} that is not s<n> the slot input, or a slot:<name> label on the PR until the PR closes (30 days idle at most); never evicted

Why a pool: Let's Encrypt allows ~50 new certificates per domain per week and this Forgejo sees 40–65 PRs a week, so a hostname per PR is not possible. A PR keeps its slot across pushes; the comment is the source of truth for the URL.

Sharing a preview with someone who has no Pocket ID

Every slot is behind the Pocket ID gate by default. A custom slot can instead get basic auth or no auth at all; that is configured on the host, not here (stacks/edge/proxy/nginx.conf.template in the homelab repo, blocks 2 and 3, plus a line in preview.htpasswd for basic auth). Then label the PR slot:<name>.

Inputs

input default
name repo name the preview's identity within the repo; one comment and one container per name
path . where to look for the Dockerfile / build output
install auto command at the repo root, or none
build auto command at the repo root, or none
output auto build output dir relative to path
slot pool pin to a custom slot
pool 8 pool size; must match the markers on the host
env KEY=VALUE per line for the container; secrets are fine (${{ secrets.X }})
memory 512m container memory limit
domain preview.rensebakker.nl parent domain

Outputs: url, slot.

Limits

  • Previews share a 4-thread box with CI and everything else: use paths: filters and skip drafts in busy repos.
  • A preview that needs a database must bring it (a Dockerfile.preview with an embedded one, or an env pointing at a throwaway) — nothing here provisions one.
  • Forks get no secrets and a read-only token; the action does not try.
  • The host side (nginx blocks, the slot markers, the hourly reaper) lives in the homelab repo.