Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
name: Test

on:
pull_request:
push:
branches: [main]

permissions:
contents: read

jobs:
action-script:
name: Action script
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: ./test/run-tests.sh
62 changes: 62 additions & 0 deletions INSTALL.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,68 @@ If you use **GitHub Repository Rulesets** (recommended) to enforce this check, f

---

## Gate the deploy itself (Vercel, Netlify, Railway, Fly.io)

The PR gate stops unapproved code merging. To also stop it shipping, run the deploy from a workflow that redeems the same approval. In `mode: deploy` (the default on `push` and `workflow_dispatch`), Deploy Gate:

1. Finds the pull request that merged the commit being deployed.
2. Verifies the receipt a signer approved for that PR's head commit.
3. Redeems it, so one approval ships once.

It fails closed when the commit did not come from a merged PR (a direct push), when no approval exists, or when the approval was already used. `redeem: false` and `fail-on-missing: false` are rejected in this mode.

```yaml
# .github/workflows/deploy.yml
name: Deploy

on:
push:
branches: [main]

concurrency:
group: production-deploy
cancel-in-progress: false

jobs:
deploy:
runs-on: ubuntu-latest
environment: production # holds the provider token; restrict it to main
permissions:
contents: read
pull-requests: read # to find the merged PR
steps:
- uses: actions/checkout@v4
- uses: permission-protocol/deploy-gate@v2
with:
pp-api-key: ${{ secrets.PP_API_KEY }}
# Must match the PR gate's environment and capability.
- run: npx vercel deploy --prod --yes --token "$VERCEL_TOKEN"
env:
VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }}
VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }}
VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }}
```

Swap the last step for your provider:

| Provider | Deploy step | Turn off its own deploys |
| --- | --- | --- |
| Vercel | `npx vercel deploy --prod --yes --token "$VERCEL_TOKEN"` | `"git": { "deploymentEnabled": false }` in `vercel.json` |
| Netlify | `npx netlify-cli deploy --prod --dir <build dir>` with `NETLIFY_AUTH_TOKEN`, `NETLIFY_SITE_ID` | Site settings → Build & deploy → Stop builds |
| Railway | `npx @railway/cli up --service <service>` with `RAILWAY_TOKEN` | Service settings → disconnect the GitHub repo trigger |
| Fly.io | `flyctl deploy --remote-only` with `FLY_API_TOKEN` (via `superfly/flyctl-actions/setup-flyctl`) | Remove any GitHub deploy integration |

The gate only holds if this workflow is the only way to deploy:

- **Turn off the provider's own Git deploys** (table above). Otherwise the provider ships every push to `main` before this workflow runs.
- **Keep the provider token in a GitHub Environment** restricted to `main`, so a pull request workflow cannot read it.
- **Gate changes to `.github/workflows/`** at the PR gate, so nobody can delete the deploy gate step without a signature.
- **Anyone with the provider's dashboard, CLI or token can still deploy around it.** Limit who holds them.

Production approvals expire 15 minutes after they are signed, so merge promptly after approval (GitHub auto-merge does this for you). A deploy that runs after the approval expired fails with `RECEIPT_EXPIRED`. If the gate passed but a later step failed, the approval is already used; re-running fails with `RECEIPT_ALREADY_REDEEMED`.

---

## Common Errors

### 401 Unauthorized
Expand Down
25 changes: 25 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,31 @@ gh secret set PP_API_KEY -b "pp_live_..."

Full install guide: [INSTALL.md](./INSTALL.md)

### Gate the deploy too

On `push` and `workflow_dispatch` the action runs in deploy mode: it finds the PR that merged the commit, verifies that PR's approval, and redeems it once. Put it before your Vercel, Netlify, Railway or Fly.io deploy step and turn off the provider's own Git deploys. See [INSTALL.md](./INSTALL.md#gate-the-deploy-itself-vercel-netlify-railway-flyio).

```yaml
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
environment: production
permissions:
contents: read
pull-requests: read
steps:
- uses: actions/checkout@v4
- uses: permission-protocol/deploy-gate@v2
with:
pp-api-key: ${{ secrets.PP_API_KEY }}
- run: npx vercel deploy --prod --yes --token "$VERCEL_TOKEN"
env:
VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }}
```

## Why this exists

AI agents are moving from "suggest text" to "take actions": committing code, modifying workflows, and deploying to production. GitHub controls like branch protection, environments, and required reviewers gate humans, not agents.
Expand Down
148 changes: 141 additions & 7 deletions action.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Version: v2.2.0
# Version: v2.3.0
name: 'Deploy Gate'
description: 'Block AI agents from production deploys without a human-signed Ed25519 receipt. Fails closed by default.'
author: 'Permission Protocol'
Expand Down Expand Up @@ -27,10 +27,14 @@ inputs:
description: 'Capability required for this gate'
required: false
default: 'deploy:production'
mode:
description: 'pr (gate a pull request), deploy (gate a deploy of a merged PR on push or workflow_dispatch), or auto (pr on pull_request events, deploy otherwise)'
required: false
default: 'auto'
redeem:
description: 'Redeem receipt on verify (use false for PR gates, true for deploy workflows)'
description: 'Redeem receipt on verify. Defaults to false in pr mode. Deploy mode always redeems; setting false there is an error.'
required: false
default: 'false'
default: ''
protected-paths:
description: 'Regex pattern for protected paths used for risk assessment metadata (not gating)'
required: false
Expand Down Expand Up @@ -78,6 +82,15 @@ outputs:
approval-url:
description: 'URL to approve the deploy'
value: ${{ steps.verify.outputs.approval-url }}
mode:
description: 'The mode that ran: pr or deploy'
value: ${{ steps.verify.outputs.mode }}
pr-number:
description: 'The pull request whose approval was checked (in deploy mode, the merged PR)'
value: ${{ steps.verify.outputs.pr-number }}
pr-head-sha:
description: 'The approved head SHA that was checked'
value: ${{ steps.verify.outputs.pr-head-sha }}

runs:
using: 'composite'
Expand All @@ -102,6 +115,10 @@ runs:
PP_PR_NUMBER: ${{ github.event.pull_request.number }}
PP_PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
PP_PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
PP_MODE: ${{ inputs.mode }}
PP_EVENT_NAME: ${{ github.event_name }}
PP_SHA: ${{ github.sha }}
PP_REF: ${{ github.ref }}
PP_RUN_ID: ${{ github.run_id }}
GH_TOKEN: ${{ github.token }}
run: |
Expand Down Expand Up @@ -210,8 +227,62 @@ runs:
exit 1
fi

# Stops the gate before any API call. Always closed: these are
# configuration or context errors, not an unavailable PP API.
gate_error() {
local code="$1"
local message="$2"
echo "::error title=Permission Protocol::${message}"
set_output "approved" "false"
set_output "receipt-id" ""
set_output "decision" ""
set_output "error-code" "$code"
set_output "error-message" "$message"
set_output "request-id" ""
set_output "approval-url" ""
exit 1
}

# ---------------------------------------------------------------
# Mode. pr gates a pull request; deploy gates shipping a merged PR
# by redeeming the receipt its signer approved at the PR gate.
# ---------------------------------------------------------------
PP_MODE_NORMALIZED=$(echo "${PP_MODE:-auto}" | tr '[:upper:]' '[:lower:]')
case "$PP_MODE_NORMALIZED" in
auto)
case "$PP_EVENT_NAME" in
pull_request|pull_request_target) PP_MODE_NORMALIZED=pr ;;
push|workflow_dispatch) PP_MODE_NORMALIZED=deploy ;;
*) gate_error "PP_UNSUPPORTED_EVENT" "mode=auto cannot choose a mode for '${PP_EVENT_NAME}' events. Set mode: pr or mode: deploy." ;;
esac
;;
pr|deploy) ;;
*) gate_error "PP_INVALID_MODE" "mode must be pr, deploy or auto (got '${PP_MODE}')." ;;
esac
set_output "mode" "$PP_MODE_NORMALIZED"
echo "Mode: ${PP_MODE_NORMALIZED}"

if [ "$PP_MODE_NORMALIZED" = "pr" ] && [ -z "${PP_PR_NUMBER}" ]; then
gate_error "PP_NO_PR_CONTEXT" "mode=pr needs a pull_request event; '${PP_EVENT_NAME}' has no pull request. To gate a deploy after merge, use mode: deploy."
fi

if [ "$PP_MODE_NORMALIZED" = "deploy" ]; then
case "$PP_EVENT_NAME" in
push|workflow_dispatch) ;;
*) gate_error "PP_UNSUPPORTED_EVENT" "mode=deploy runs on push or workflow_dispatch, never on '${PP_EVENT_NAME}'. A pull request cannot redeem a deploy approval." ;;
esac
# A deploy that does not redeem can be replayed; one that passes with
# no receipt is not gated at all. Neither is a deploy gate.
if [ "${PP_REDEEM}" = "false" ]; then
gate_error "PP_INVALID_CONFIG" "mode=deploy always redeems the receipt so one approval ships once. Remove redeem: false."
fi
if [ "${PP_FAIL_ON_MISSING}" = "false" ]; then
gate_error "PP_INVALID_CONFIG" "mode=deploy always fails without a receipt. Remove fail-on-missing: false."
fi
fi

REDEEM_JSON=false
if [ "${PP_REDEEM}" = "true" ]; then
if [ "$PP_MODE_NORMALIZED" = "deploy" ] || [ "${PP_REDEEM}" = "true" ]; then
REDEEM_JSON=true
fi

Expand All @@ -238,9 +309,54 @@ runs:
fi
done < <(normalize_csv_items "${PP_PRODUCTION_ENVIRONMENTS}")

# ---------------------------------------------------------------
# Deploy mode: find the PR this commit merged. The receipt a signer
# approved is keyed to that PR's head SHA, so that is what we verify.
# A commit no PR merged (a direct push) was never approved.
# ---------------------------------------------------------------
if [ "$PP_MODE_NORMALIZED" = "deploy" ]; then
case "$PP_REF" in
refs/heads/*) DEPLOY_BRANCH="${PP_REF#refs/heads/}" ;;
*) gate_error "PP_UNSUPPORTED_REF" "mode=deploy deploys a branch; '${PP_REF}' is not one." ;;
esac

echo "🔍 Finding the pull request that merged ${PP_SHA:0:10} into ${DEPLOY_BRANCH}..."
set +e
ASSOCIATED_PRS=$(gh api "repos/${REPO}/commits/${PP_SHA}/pulls" 2>&1)
GH_EXIT=$?
set -e
if [ "$GH_EXIT" -ne 0 ]; then
gate_error "PP_PR_LOOKUP_FAILED" "Could not list pull requests for ${PP_SHA:0:10} (the job needs pull-requests: read). ${ASSOCIATED_PRS}"
fi

# merge_commit_sha is the commit a merge, squash or rebase merge left
# on the base branch, so it must equal the commit being deployed.
MERGED_PRS=$(echo "$ASSOCIATED_PRS" | jq -c --arg sha "$PP_SHA" --arg branch "$DEPLOY_BRANCH" \
'if type == "array" then [ .[] | select(.merged_at != null and .merge_commit_sha == $sha and .base.ref == $branch) ] else error("not a list") end' 2>/dev/null || echo "invalid")
if [ "$MERGED_PRS" = "invalid" ]; then
gate_error "PP_PR_LOOKUP_FAILED" "GitHub returned an unreadable pull request list for ${PP_SHA:0:10}."
fi
MERGED_COUNT=$(echo "$MERGED_PRS" | jq 'length')
if [ "$MERGED_COUNT" -eq 0 ]; then
gate_error "PP_NO_MERGED_PR" "${PP_SHA:0:10} on ${DEPLOY_BRANCH} was not produced by merging a pull request, so no signer approved it. Deploy only commits that merged through a gated PR."
fi
if [ "$MERGED_COUNT" -ne 1 ]; then
gate_error "PP_PR_LOOKUP_FAILED" "${MERGED_COUNT} merged pull requests claim ${PP_SHA:0:10}; refusing to guess which approval applies."
fi

PP_PR_NUMBER=$(echo "$MERGED_PRS" | jq -r '.[0].number')
PP_PR_HEAD_SHA=$(echo "$MERGED_PRS" | jq -r '.[0].head.sha')
PP_PR_BASE_SHA=$(echo "$MERGED_PRS" | jq -r '.[0].base.sha')
# The PR is merged; a comment there tells no one about this deploy.
PP_POST_COMMENT=false
echo "Merged PR: #${PP_PR_NUMBER} (approved head ${PP_PR_HEAD_SHA:0:10})"
fi
set_output "pr-number" "$PP_PR_NUMBER"
set_output "pr-head-sha" "$PP_PR_HEAD_SHA"

# Skip the gate entirely for draft PRs — not ready for review/approval.
# When the PR is marked ready for review, the gate will run again.
if [ -n "${PP_PR_NUMBER}" ]; then
if [ "$PP_MODE_NORMALIZED" = "pr" ] && [ -n "${PP_PR_NUMBER}" ]; then
IS_DRAFT=$(gh pr view "${PP_PR_NUMBER}" --json isDraft --jq '.isDraft' 2>/dev/null || echo "false")
if [ "$IS_DRAFT" = "true" ]; then
echo "⏭️ Skipping Permission Protocol gate — PR #${PP_PR_NUMBER} is a draft"
Expand Down Expand Up @@ -412,6 +528,9 @@ runs:
echo ""
# Details (collapsed in logs, visible if expanded)
echo "Details: repo=${REPO} pr=#${PP_PR_NUMBER} sha=${PP_PR_HEAD_SHA:0:10}"
if [ "$PP_MODE_NORMALIZED" = "deploy" ]; then
echo "Deploying ${PP_SHA:0:10}, merged from PR #${PP_PR_NUMBER}. Approve that PR's request, then re-run this workflow."
fi
if [ -n "$REQUEST_ID" ]; then
echo "Request: ${REQUEST_ID}"
fi
Expand All @@ -432,12 +551,27 @@ runs:
;;
"RECEIPT_ALREADY_REDEEMED")
echo "❌ FAIL: Receipt already redeemed"
echo "This permission has already been consumed."
if [ "$PP_MODE_NORMALIZED" = "deploy" ]; then
echo "The approval for PR #${PP_PR_NUMBER} was already used by an earlier deploy. One approval ships once."
else
echo "This permission has already been consumed."
fi
exit 1
;;
"RECEIPT_EXPIRED")
echo "❌ FAIL: Receipt expired"
echo "Request a new approval and re-run."
REAPPROVAL_URL=$(echo "$HTTP_BODY" | jq -r 'select(.reapproval.autoRenewEligible == true) | .reapproval.approvalUrl // empty' 2>/dev/null || echo "")
REAPPROVAL_REASON=$(echo "$HTTP_BODY" | jq -r '.reapproval.reason // empty' 2>/dev/null || echo "")
if [ -n "$REAPPROVAL_URL" ]; then
echo "A signer can renew this approval, then re-run this workflow straight away:"
echo " 👉 ${REAPPROVAL_URL}"
set_output "approval-url" "$REAPPROVAL_URL"
else
echo "Request a new approval and re-run."
if [ -n "$REAPPROVAL_REASON" ]; then
echo "Renewal is not available: ${REAPPROVAL_REASON}"
fi
fi
exit 1
;;
"RECEIPT_DENIED")
Expand Down
Loading
Loading