Skip to main content

Deploy Approval Pattern

How we replicate GitLab's when: manual deploy behavior on GitHub Actions under trunk-based development (main only). This document covers: the split-gate pattern (a tiny approval job plus a deploy job), the concurrency rules, and the two variants (Laravel Cloud, Laravel Vapor).

All Publica.la GitHub-hosted repos use this pattern. Branch strategy (why trunk-only) lives in Branch Strategy.

Problem​

GitLab CI has when: manual, which puts a clickable deploy button inside the MR pipeline view and does not occupy its resource_group until clicked.

GitHub Actions has no direct equivalent. Environment protection rules (required reviewers) look similar at first glance, but a job awaiting environment approval is already queued and already holding its concurrency: slot while it waits. The difference matters when multiple deploy jobs share a concurrency group; the "manual" one can block the automatic one.

Background: the deadlock this replaces​

Incident: 2026-04-17

A merged PR's manual-deploy-staging job in farfalla-integrations sat in waiting status for ~20 hours because no one clicked "Review deployments". It shared concurrency: deploy-staging with the push-to-main deploy-staging job and cancel-in-progress: false. The next main push deploy was queued behind the stale approval indefinitely. The split-gate pattern below is the permanent fix.

Pre-fix workflows gated the real deploy job with environment: staging-review. When that job was waiting for a reviewer, the concurrency group was held. Any subsequent deploy to the same environment was stuck behind it. The approval UX was fine; the coupling was not.

Solution: split-gate​

Split "get human approval" from "do the deploy" into two jobs:

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”       β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ approve-staging-review β”‚ β”‚ deploy-staging-review β”‚
β”‚ β”‚ β†’ β”‚ β”‚
β”‚ environment: staging-reviewβ”‚ needs β”‚ (concurrency: deploy-staging) β”‚
β”‚ required reviewers β”‚ β”‚ does the deploy β”‚
β”‚ no concurrency β”‚ β”‚ β”‚
β”‚ step: echo "approved" β”‚ β”‚ β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
  • Job A owns the reviewer gate (environment: staging-review). It has no concurrency: key, so a pending approval holds no slot. Its only step is echo approved. The PR still shows the "Review deployments" banner because the banner tracks environment-gated jobs, not what those jobs do.
  • Job B owns the work and the concurrency: group. It only enters the queue after Job A passes, i.e. after human approval. Stale approvals on merged PRs cannot deadlock main's push deploy.

The workflow's top-level concurrency: ${{ github.workflow }}-${{ github.ref }} with cancel-in-progress: true handles the "superseded pushes on the same PR" case, so Job A does not need its own concurrency group.

Architecture​

Three environments handle three deploy scenarios. This has not changed; what changed is that the gate and the deploy now live on different jobs for staging-review.

EnvironmentProtectionWho can deployPurpose
stagingBranch policy (protected branches)main pushes onlyAuto-deploy after merge
staging-reviewCore Team reviewersAny PR, after Core Team approvalManual deploy from feature branches
productionCore Team reviewersmain pushes, after Core Team approvalProduction with reviewer gate

Both staging environments deploy to the same staging server (same hook or Vapor env); the split exists so auto-deploy on merge does not require approval while PR previews still do.

Variants​

Both variants use the same Job A. They differ only in Job B's environment: reference and where Job B's deploy secret lives.

Laravel Cloud (coniglio, medusa, farfalla-https-guard, mentat)​

Job B can reference environment: staging because those environments have no reviewer gate and, in Cloud repos, no branch-policy restriction. The deploy-hook secret lives on the staging environment.

Laravel Vapor (farfalla-integrations, farfalla, castoro)​

The staging environment typically has deployment_branch_policy: protected_branches (set during migration to prevent feature branches from deploying straight to the shared staging infra bypassing review). That makes it impossible for Job B (PR-triggered, running on a feature branch) to reference environment: staging without tripping the branch policy.

For Vapor repos, Job B has no environment: reference. The Vapor token (VAPOR_API_TOKEN) is stored as a repo-level secret so Job B can pull it without declaring an environment.

Env-scoped secrets vs repo-level

Env-scoped secrets override repo-level secrets of the same name. You can keep VAPOR_API_TOKEN on the staging / production environments for the auto-deploy jobs that reference those environments, and also keep a repo-level copy for the no-env Job B. Simpler: drop the env copies entirely and rely on the single repo-level secret.

Setup​

1. Create environments​

REPO="publicala/<repo>"
CORE_TEAM_ID=16785864

# staging: deploy from protected branches only (no reviewer gate)
gh api repos/$REPO/environments/staging -X PUT --input - <<EOF
{
"deployment_branch_policy": { "protected_branches": true, "custom_branch_policies": false }
}
EOF

# staging-review: Core Team approval required
gh api repos/$REPO/environments/staging-review -X PUT --input - <<EOF
{
"reviewers": [{ "type": "Team", "id": $CORE_TEAM_ID }]
}
EOF

# production: Core Team approval required
gh api repos/$REPO/environments/production -X PUT --input - <<EOF
{
"reviewers": [{ "type": "Team", "id": $CORE_TEAM_ID }]
}
EOF
info

The Core Team ID (16785864) is the same for all repos in the publicala org. Query it with gh api orgs/publicala/teams/core-team --jq .id.

2. Add deploy secrets​

Scope rules (applies to every repo):

ScopeVisible toOverride semantics
OrganizationEvery workflow in every repo in the orgOverridden by repo- or env-level secrets with the same name
RepositoryEvery job in the repo, including jobs with no environment:Overridden by env-scoped secrets with the same name
EnvironmentOnly jobs that reference that specific environmentWins over repo- and org-level secrets with the same name

Two rules to keep in mind when placing secrets:

  1. A job with no environment: reference can only read repo- and org-level secrets. In the split-gate pattern, Vapor's Job B (deploy-staging-review) has no env reference, so its token must live at repo level.
  2. Env-scoped secrets are the right place for a stricter, per-environment value (typically, a token with narrower permissions than the repo-level default). Since they override by name, no workflow change is needed to pick them up.

Laravel Cloud repos:

REPO="publicala/<repo>"

# Staging hook: env-scoped to `staging`. Both deploy-staging-review (PR
# preview) and deploy-staging (push-to-main) reference `environment: staging`
# and read the hook from there. Cloud's `staging` env has no branch-policy
# restriction, so PR branches can use it.
printf '%s' '<staging-hook-url>' | gh secret set LARAVEL_CLOUD_STAGING_DEPLOY_HOOK -R $REPO --env staging

# Production hook: env-scoped to `production`. Only deploy-production reads it.
printf '%s' '<production-hook-url>' | gh secret set LARAVEL_CLOUD_PRODUCTION_DEPLOY_HOOK -R $REPO --env production

The staging-review environment does not need any deploy-hook secret. Its only job is the approval gate (echo approved); clicking "Approve" on it is what unlocks Job B to run.

Laravel Vapor repos:

REPO="publicala/<repo>"

# Repo-level: used by Job B (deploy-staging-review, no env reference) AND by
# deploy-staging (env: staging) via fallback. Mint this token in the Vapor
# dashboard with permissions scoped to staging-only deploys.
printf '%s' '<vapor-staging-only-token>' | gh secret set VAPOR_API_TOKEN -R $REPO

# Env-scoped on `production`: defense-in-depth. deploy-production reads this
# and the env-scoped value overrides the repo-level one. Mint a distinct
# production-only token so a compromised staging token cannot deploy prod.
printf '%s' '<vapor-production-only-token>' | gh secret set VAPOR_API_TOKEN -R $REPO --env production

Do not add an env-scoped VAPOR_API_TOKEN on staging or staging-review:

  • staging-review does not run the deploy (only the approval).
  • staging would create a confusing split: deploy-staging would use the env-scoped copy while Job B (no env) would use the repo-level one. If the values are the same, the duplication is noise; if they differ, one of them is wrong.
Different deploy targets, different secret stories

The Cloud and Vapor sections look different because they solve different constraints. Cloud's staging env has no branch-policy restriction, so Job B can reference it and read env-scoped secrets normally. Vapor's staging env typically has deployment_branch_policy: protected_branches, which blocks Job B from referencing it, so Job B falls back to repo-level. Both patterns end at the same place: one token per security boundary, placed at the narrowest scope that every reader can reach.

Prefer stdin over --body

Feed the secret value via stdin (printf '%s' '...' | gh secret set NAME ...) instead of gh secret set NAME --body '<value>'. --body takes a literal string, so a leading - is parsed as a flag and a $ gets expanded by the shell. Pipe via stdin and neither is an issue.

3. Workflow jobs​

Trunk-based, one branch. Feature branches open PRs against main; merges to main auto-deploy staging and wait for approval on production.

name: CI

on:
push:
branches: [main]
pull_request:
branches: [main]
workflow_dispatch:

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}

jobs:
# ... build, test, phpstan, lint jobs ...

# Approval gate for PR preview deploys. Owns NO concurrency so a pending
# approval cannot block main's deploy-staging.
approve-staging-review:
if: github.event_name == 'pull_request'
needs: [test, phpstan, lint-php, lint-js, build-js]
runs-on: depot-ubuntu-24.04
environment:
name: staging-review
url: https://staging-<project>.publica.la
steps:
- run: echo "Approved. Deploying."

# Laravel Cloud variant: references environment: staging to inherit the hook secret.
deploy-staging-review:
if: github.event_name == 'pull_request'
needs: [approve-staging-review]
runs-on: depot-ubuntu-24.04
concurrency:
group: deploy-staging
cancel-in-progress: false
environment:
name: staging
url: https://staging-<project>.publica.la
steps:
- name: Deploy to Laravel Cloud
run: |
curl --fail-with-body -sS \
--retry 3 --retry-all-errors \
--connect-timeout 10 --max-time 30 \
-X POST "${{ secrets.LARAVEL_CLOUD_STAGING_DEPLOY_HOOK }}?commit_hash=${{ github.sha }}"

# Auto-deploy on merge to main.
deploy-staging:
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
needs: [test, phpstan, lint-php, lint-js, build-js]
runs-on: depot-ubuntu-24.04
concurrency:
group: deploy-staging
cancel-in-progress: false
environment:
name: staging
url: https://staging-<project>.publica.la
steps:
- name: Deploy to Laravel Cloud
run: |
curl --fail-with-body -sS \
--retry 3 --retry-all-errors \
--connect-timeout 10 --max-time 30 \
-X POST "${{ secrets.LARAVEL_CLOUD_STAGING_DEPLOY_HOOK }}?commit_hash=${{ github.event.pull_request.head.sha }}"

# Production deploy, gated by environment reviewer.
deploy-production:
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
needs: [test, phpstan, lint-php, lint-js, build-js]
runs-on: depot-ubuntu-24.04
concurrency:
group: deploy-production
cancel-in-progress: false
environment:
name: production
url: https://<project>.publica.la
steps:
- name: Deploy to Laravel Cloud
run: |
curl --fail-with-body -sS \
--retry 3 --retry-all-errors \
--connect-timeout 10 --max-time 30 \
-X POST "${{ secrets.LARAVEL_CLOUD_PRODUCTION_DEPLOY_HOOK }}?commit_hash=${{ github.sha }}"
Two concurrency layers

The top-level concurrency block cancels in-flight test runs when a new push lands on the same ref (cheaper feedback loops). The per-job deploy concurrency groups use cancel-in-progress: false so a new deploy queues behind an in-flight one instead of interrupting it.

cancel-in-progress is conditional on the ref (!= refs/heads/main) on purpose. Workflow-level cancellation overrides per-job cancel-in-progress: false and would otherwise cancel an in-flight main deploy when a new push to main arrives. Excluding main keeps deploy queueing intact while still cancelling superseded runs on feature branches.

Two gotchas in the curl above

Both patterns are documented in full in the CI/CD Translation Reference Β§ Laravel Cloud Deploys:

  • --fail-with-body β€” without it, curl returns 0 on a 4xx/5xx and a rejected deploy hook silently marks the job green.
  • github.event.pull_request.head.sha in deploy-staging-review (not github.sha) β€” on pull_request events github.sha is an ephemeral merge commit that doesn't exist on the PR branch. The two push-triggered jobs (deploy-staging, deploy-production) keep github.sha.

Vapor variant (Job B only)​

For Laravel Vapor repos, deploy-staging-review has no environment: reference and uses the repo-level VAPOR_API_TOKEN:

deploy-staging-review:
if: github.event_name == 'pull_request'
needs: [approve-staging-review]
runs-on: depot-ubuntu-24.04-16
concurrency:
group: deploy-staging
cancel-in-progress: false
steps:
- uses: actions/checkout@v6
- uses: shivammathur/setup-php@v2
with: { php-version: '8.3', extensions: pdo_mysql, coverage: none }
- uses: actions/download-artifact@v8
with: { name: assets, path: public/ }
- name: Install Vapor CLI
run: composer global require laravel/vapor-cli:^1 --no-interaction
- name: Deploy via Vapor
env:
VAPOR_API_TOKEN: ${{ secrets.VAPOR_API_TOKEN }} # repo-level
GITHUB_COMMIT_MESSAGE: ${{ github.event.pull_request.title }}
run: ./deploy.sh staging

deploy-staging and deploy-production continue to reference their environments (staging, production) where env-scoped settings and branch policies apply normally.

Developer workflow​

Deploy a feature branch to staging (PR preview)​

  1. Open a PR targeting main.
  2. CI runs (build, test, phpstan, lint).
  3. After CI passes, approve-staging-review shows as "Waiting" in the PR checks.
  4. Click "Review pending deployments" at the bottom of the checks list.
  5. Select the staging-review environment and click "Approve and deploy".
  6. Job A completes immediately; Job B (deploy-staging-review) runs the actual deploy and updates staging.

Deploy to production​

  1. Merge a PR to main.
  2. CI runs automatically; deploy-staging deploys staging in the background.
  3. deploy-production shows as "Waiting".
  4. A Core Team member opens the workflow run and clicks "Review pending deployments" for the production environment.
  5. After approval, the production deploy runs.

Comparison with GitLab​

AspectGitLabGitHub (split-gate)
Manual deploywhen: manual button in pipeline viewEnvironment approval in workflow run (via Job A)
Waiting manual deploy occupies concurrency?No (resource_group unheld)No (Job A has no concurrency; Job B only enters after approval)
VisibilityInline in MR pipelineIn PR checks (linked from the PR)
Who can approveAnyone with merge accessConfigured per-environment (team or users)
Multiple approversNot nativeSupports teams and multiple reviewers
Self-approvalAlways allowedConfigurable (prevent_self_review)

Key details​

  • Environment protection only affects Actions jobs. It does not affect PR approvals, merges, or branch protection. Those are configured separately via branch rulesets.
  • Job A can be a no-op run: echo. GitHub's model is "a job that references an environment must pass protection rules before running", not "a job must perform a deployment step". The approval UI hooks into the environment reference, not the job's contents.
  • Do not add concurrency: to Job A. Adding it reintroduces the deadlock. The workflow-level ${{ github.workflow }}-${{ github.ref }} concurrency already handles superseded pushes on the same PR.
  • Env-scoped secrets override repo-level secrets of the same name. Jobs with no environment: reference (Vapor's Job B) can only read repo- and org-level secrets. See Β§2 Add deploy secrets for the scoping table and placement rules.
  • prevent_self_review: false (default): the developer who pushed can also approve the deploy. Set to true if a second pair of eyes is required.
  • Job A will create a deployment record in the repo's Deployments history. Cosmetic only.
X

Graph View