A preview environment per pull request
The payoff of branch environments is that reviewers click a URL and see the change running instead of reading a diff. This guide wires that into CI so it happens on every pull request automatically: push a branch, a preview provisions, its URL lands on the PR, and the environment is torn down when the PR closes.
The provisioning itself is just git push managed — or one
mf CLI command. The CI glue that posts URLs and gates merges is
built on the public API and
webhooks, both live today.
The lifecycle
Section titled “The lifecycle”- A contributor pushes a branch and opens a PR.
- CI provisions an isolated preview environment for the branch, seeded from production.
- CI posts the preview URL back to the PR as a comment and a status check.
- Reviewers and QA work against the live URL; you can gate the merge on observability before promoting.
- When the PR closes or merges, CI tears the environment down.
Mint a CI key
Section titled “Mint a CI key”Automation should run on its own narrowly scoped API key, not a personal one. Mint a key with just the scopes this workflow needs:
mf keys create --name pr-preview-bot \ --scopes sites:read,environments:write,jobs:read,observability:read \ --ttl 2160hThe secret is printed exactly once — store it as a CI secret named
FORGE_TOKEN. The mf CLI and the API examples below both read it from that
environment variable, so nothing else needs configuring in the job.
Provision on push
Section titled “Provision on push”A preview is created whenever you push a non-default branch that isn’t routed to
staging or ignore — see branch routes. In CI you
can rely on that, or create the environment explicitly, which is easier to script
because the command blocks until provisioning finishes and hands you the
environment id:
mf envs create --type branch --branch "$BRANCH_NAME" \ --site "$SITE_ID" --wait --json > job.jsonENV_ID=$(jq -r '.result.env_id' job.json)PREVIEW_URL="https://$(mf envs get "$ENV_ID" --site "$SITE_ID" --json | jq -r '.subdomain')"--wait polls the job to completion and exits
non-zero if provisioning fails, so the CI step fails with it. Subsequent pushes to
the same branch deploy into the existing environment through your git remote:
git remote add managed "$MANAGED_GIT_URL"git push managed "HEAD:$BRANCH_NAME"Post the URL back to the PR
Section titled “Post the URL back to the PR”If you didn’t capture the URL at create time, read the environment back — the
branch is on the environment object, and subdomain is the preview hostname:
curl -s "https://api.managed.dev/v1/sites/site_01J7ABCXYZ/environments" \ -H "Authorization: Bearer $FORGE_TOKEN" | jq -r --arg b "$BRANCH_NAME" '.data[] | select(.branch == $b).subdomain'mf envs list --site site_01J7ABCXYZ --json | jq -r --arg b "$BRANCH_NAME" '.[] | select(.branch == $b).subdomain'Then post that URL as a PR comment and a commit status. A GitHub Action that does the whole loop is forthcoming as a published, versioned action; until then, the illustrative workflow below shows the shape:
name: managed.dev previewon: pull_request: types: [opened, synchronize, reopened, closed]
jobs: preview: if: github.event.action != 'closed' runs-on: ubuntu-latest env: FORGE_TOKEN: ${{ secrets.FORGE_TOKEN }} steps: - uses: actions/checkout@v4 with: { fetch-depth: 0 } - name: Install the mf CLI run: go install terriblegit.com/terrible/mf/cmd/mf@latest - name: Ensure the preview environment exists run: | if ! mf envs list --site "${{ vars.SITE_ID }}" --json | jq -e --arg b "${{ github.head_ref }}" 'any(.[]; .branch == $b)' >/dev/null; then mf envs create --type branch --branch "${{ github.head_ref }}" \ --site "${{ vars.SITE_ID }}" --wait fi - name: Push the head commit run: | git remote add managed "${{ secrets.MANAGED_GIT_URL }}" git push managed "HEAD:${{ github.head_ref }}" - name: Comment preview URL run: | url="https://$(mf envs list --site "${{ vars.SITE_ID }}" --json | jq -r --arg b "${{ github.head_ref }}" '.[] | select(.branch == $b).subdomain')" ./scripts/post-preview-url.sh "$url"Gate the merge on observability
Section titled “Gate the merge on observability”Because previews carry the full observability pipeline, you can make “no new errors” a merge requirement instead of a vibe. Query the preview’s traces for error-status rows and fail the check if any appear:
errors=$(curl -s "https://api.managed.dev/v1/sites/$SITE_ID/environments/$ENV_ID/insights/traces?status=error&limit=1" \ -H "Authorization: Bearer $FORGE_TOKEN" | jq '.data.traces | length')test "$errors" -eq 0 || { echo "preview has error traces — blocking merge"; exit 1; }This is the deploy-gated-on-observability pattern: you only promote a branch that’s demonstrably clean on its own preview.
Tear down on close
Section titled “Tear down on close”When the PR closes, delete the environment so it doesn’t linger. Deleting the branch also tears the preview down automatically, but an explicit call is cleaner in CI:
mf envs delete "$ENV_ID" --site "$SITE_ID" --waitTeardown is an async job like everything else; --wait confirms it finished.
Production is never removed this way — this workflow only ever deletes the PR’s
own branch environment.