Deploy Approval Pattern
How to replicate GitLab's when: manual deploy behavior in GitHub Actions using environment protection rules. This pattern gives developers an approval button visible in the PR checks, similar to GitLab's inline manual deploy button.
Problemโ
GitLab CI has when: manual, which adds a clickable deploy button directly in the MR pipeline view. GitHub Actions has no direct equivalent. workflow_dispatch exists but lives in a separate Actions tab, disconnected from the PR context.
Solutionโ
Use GitHub environment protection rules (required reviewers) to gate deploy jobs. When a job declares an environment with protection rules, it pauses and displays a "Review pending deployments" button in the workflow run, which is linked from the PR checks.
Architectureโ
Three environments handle three deployment scenarios:
| Environment | Protection | Purpose |
|---|---|---|
staging | None | Auto-deploy after a push lands |
staging-review | Core Team reviewers | Manual deploy from feature branches |
production | Core Team reviewers | Deploy to production with approval |
Why two staging environmentsโ
If staging has required reviewers, every deploy to staging requires approval, including the automatic deploy after merging. Splitting into staging (no gate) and staging-review (with gate) keeps auto-deploy frictionless while giving feature branches an explicit approval step.
Both environments use the same deploy hook URL and deploy to the same staging server. The only difference is the protection rules.
Branch-model variantsโ
Two branch layouts are in use across migrated repos. The environment model above applies to both; only the workflow triggers differ.
| Variant | Used by | Staging auto-deploy trigger | Staging-review trigger | Production trigger |
|---|---|---|---|---|
dev + main (feature โ dev โ main) | coniglio, medusa | Push to dev | PR targeting dev | Push to main |
main-only (feature โ main) | farfalla-integrations, farfalla-https-guard | Push to main | PR targeting main | Push to main (same) |
In the main-only variant, deploy-staging and deploy-production both run on the same push event; deploy-staging runs automatically while deploy-production waits on the production environment reviewer.
Setupโ
1. Create environmentsโ
REPO="publicala/<repo>"
CORE_TEAM_ID=16785864
# staging: no protection (auto-deploy on merge to dev)
gh api repos/$REPO/environments/staging -X PUT
# 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 hook secretsโ
Each environment needs its own copy of the deploy hook secret, because environment secrets are scoped to their environment.
REPO="publicala/<repo>"
# staging and staging-review share the same hook URL (same Cloud staging server)
printf '%s' '<staging-hook-url>' | gh secret set LARAVEL_CLOUD_STAGING_DEPLOY_HOOK -R $REPO --env staging
printf '%s' '<staging-hook-url>' | gh secret set LARAVEL_CLOUD_STAGING_DEPLOY_HOOK -R $REPO --env staging-review
# production
printf '%s' '<production-hook-url>' | gh secret set LARAVEL_CLOUD_PRODUCTION_DEPLOY_HOOK -R $REPO --env production
--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โ
Example below uses the dev + main variant. For the main-only variant, see Branch-model variants and the collapsible block further down.
name: CI
on:
push:
branches: [dev, main]
pull_request:
branches: [dev]
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
# ... build, test, phpstan, lint jobs ...
# Auto-deploy to staging when code is merged to dev
deploy-staging:
if: github.ref == 'refs/heads/dev' && github.event_name == 'push'
needs: [test, phpstan, lint]
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 }}"
# Manual deploy to staging from PRs (requires approval)
manual-deploy-staging:
if: github.event_name == 'pull_request' && github.base_ref == 'dev'
needs: [test, phpstan, lint]
runs-on: depot-ubuntu-24.04
concurrency:
group: deploy-staging
cancel-in-progress: false
environment:
name: staging-review
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 }}"
# Deploy to production (requires approval)
deploy-production:
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
needs: [test, phpstan, lint]
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 arrives 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.
--fail-with-body is load-bearingWithout --fail-with-body, curl returns 0 on a 4xx/5xx response, so a rejected deploy hook still marks the job green. Always include it on hook-firing steps.
github.event.pull_request.head.shaOn pull_request events, github.sha points to the ephemeral merge commit GitHub creates to test the PR against the base branch โ a SHA that doesn't exist on the PR's branch. Passing it to commit_hash= makes the Cloud dashboard show a commit reviewers can't find. Use github.event.pull_request.head.sha for the manual-deploy-staging job so Cloud displays the actual PR HEAD. push-triggered jobs (deploy-staging, deploy-production) keep github.sha โ it's the real commit there.
main-only variant (no dev branch)
For repos where feature branches target main directly, collapse the trigger block and drop the dev conditions. deploy-staging and deploy-production both fire on the same push to main; only deploy-production waits on its environment reviewer.
on:
push:
branches: [main]
pull_request:
branches: [main]
workflow_dispatch:
jobs:
deploy-staging:
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
# ... rest unchanged ...
manual-deploy-staging:
if: github.event_name == 'pull_request'
# ... rest unchanged ...
deploy-production:
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
# ... rest unchanged ...
4. Vapor deploy stepโ
For Laravel Vapor repos, the environment/concurrency shape is identical; only the deploy step changes. Replace the curl call with vapor deploy:
steps:
- uses: actions/checkout@v6
- uses: shivammathur/setup-php@728c6c6b8cf02c2e48117716a91ee48313958a19 # v2.37.0
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 }}
GITHUB_COMMIT_MESSAGE: ${{ github.event.head_commit.message }}
run: ./deploy.sh staging
The reviewer gate still comes from environment: { name: staging-review }; nothing about the approval flow changes.
Developer workflowโ
Deploy a feature branch to stagingโ
- Open a PR targeting
dev - CI runs (build, test, phpstan, lint)
- After CI passes,
manual-deploy-stagingshows 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" - The deploy hook fires and Laravel Cloud deploys the feature branch to staging
Deploy to productionโ
- Merge a PR to
main - CI runs automatically
deploy-productionshows as "Waiting"- A Core Team member goes to the workflow run and clicks "Review pending deployments"
- Approve the
productionenvironment - The deploy hook fires and Laravel Cloud deploys to production
Comparison with GitLabโ
| Aspect | GitLab | GitHub |
|---|---|---|
| Manual deploy | when: manual button in pipeline view | Environment approval in workflow run |
| 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.
- The approval button only appears after all
needsjobs pass. If CI fails, the deploy job is skipped entirely. prevent_self_review: false(default): the developer who pushed can also approve the deploy. Set totrueif a second pair of eyes is required.- Secrets are scoped per environment. A secret in
stagingis not accessible from a job usingstaging-review. Duplicate the secret in both environments.
Related docsโ
- Migration Guide ยง2.5 - per-repo checklist for creating environments and scoping secrets
- CI/CD Translation Reference -
resource_group:โconcurrency:andwhen: manualโ environment protection mappings