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β
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 noconcurrency:key, so a pending approval holds no slot. Its only step isecho 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.
| Environment | Protection | Who can deploy | Purpose |
|---|---|---|---|
staging | Branch policy (protected branches) | main pushes only | Auto-deploy after merge |
staging-review | Core Team reviewers | Any PR, after Core Team approval | Manual deploy from feature branches |
production | Core Team reviewers | main pushes, after Core Team approval | Production 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 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
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):
| Scope | Visible to | Override semantics |
|---|---|---|
| Organization | Every workflow in every repo in the org | Overridden by repo- or env-level secrets with the same name |
| Repository | Every job in the repo, including jobs with no environment: | Overridden by env-scoped secrets with the same name |
| Environment | Only jobs that reference that specific environment | Wins over repo- and org-level secrets with the same name |
Two rules to keep in mind when placing secrets:
- 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. - 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-reviewdoes not run the deploy (only the approval).stagingwould create a confusing split:deploy-stagingwould 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.
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.
--bodyFeed 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 }}"
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.
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.shaindeploy-staging-review(notgithub.sha) β onpull_requesteventsgithub.shais an ephemeral merge commit that doesn't exist on the PR branch. The twopush-triggered jobs (deploy-staging,deploy-production) keepgithub.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)β
- Open a PR targeting
main. - CI runs (build, test, phpstan, lint).
- After CI passes,
approve-staging-reviewshows as "Waiting" in the PR checks. - Click "Review pending deployments" at the bottom of the checks list.
- Select the
staging-reviewenvironment and click "Approve and deploy". - Job A completes immediately; Job B (
deploy-staging-review) runs the actual deploy and updates staging.
Deploy to productionβ
- Merge a PR to
main. - CI runs automatically;
deploy-stagingdeploys staging in the background. deploy-productionshows as "Waiting".- A Core Team member opens the workflow run and clicks "Review pending deployments" for the
productionenvironment. - After approval, the production deploy runs.
Comparison with GitLabβ
| Aspect | GitLab | GitHub (split-gate) |
|---|---|---|
| Manual deploy | when: manual button in pipeline view | Environment 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) |
| Visibility | Inline in MR pipeline | In PR checks (linked from the PR) |
| Who can approve | Anyone with merge access | Configured per-environment (team or users) |
| Multiple approvers | Not native | Supports teams and multiple reviewers |
| Self-approval | Always allowed | Configurable (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 totrueif a second pair of eyes is required.- Job A will create a deployment record in the repo's Deployments history. Cosmetic only.
Related docsβ
- Branch Strategy is the "why trunk-based".
- Dropping the
devBranch is the per-repo cutover runbook. - Migration Guide Β§2.5 has the per-repo checklist for creating environments and scoping secrets.
- CI/CD Translation Reference maps
resource_group:βconcurrency:andwhen: manualβ split-gate.