Skip to main content

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:

EnvironmentProtectionPurpose
stagingNoneAuto-deploy after a push lands
staging-reviewCore Team reviewersManual deploy from feature branches
productionCore Team reviewersDeploy 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.

VariantUsed byStaging auto-deploy triggerStaging-review triggerProduction trigger
dev + main (feature โ†’ dev โ†’ main)coniglio, medusaPush to devPR targeting devPush to main
main-only (feature โ†’ main)farfalla-integrations, farfalla-https-guardPush to mainPR targeting mainPush 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
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 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
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โ€‹

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 }}"
Two concurrency layers

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-bearing

Without --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.

PR-triggered deploys: use github.event.pull_request.head.sha

On 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โ€‹

  1. Open a PR targeting dev
  2. CI runs (build, test, phpstan, lint)
  3. After CI passes, manual-deploy-staging 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. The deploy hook fires and Laravel Cloud deploys the feature branch to staging

Deploy to productionโ€‹

  1. Merge a PR to main
  2. CI runs automatically
  3. deploy-production shows as "Waiting"
  4. A Core Team member goes to the workflow run and clicks "Review pending deployments"
  5. Approve the production environment
  6. The deploy hook fires and Laravel Cloud deploys to production

Comparison with GitLabโ€‹

AspectGitLabGitHub
Manual deploywhen: manual button in pipeline viewEnvironment approval in workflow run
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.
  • The approval button only appears after all needs jobs 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 to true if a second pair of eyes is required.
  • Secrets are scoped per environment. A secret in staging is not accessible from a job using staging-review. Duplicate the secret in both environments.
X

Graph View