diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..a19d80f --- /dev/null +++ b/.github/workflows/test.yml @@ -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 diff --git a/INSTALL.md b/INSTALL.md index d27c3a4..9f308dc 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -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 ` with `NETLIFY_AUTH_TOKEN`, `NETLIFY_SITE_ID` | Site settings → Build & deploy → Stop builds | +| Railway | `npx @railway/cli up --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 diff --git a/README.md b/README.md index 5efe087..4bf4712 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/action.yml b/action.yml index dbc95f8..521b8f3 100644 --- a/action.yml +++ b/action.yml @@ -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' @@ -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 @@ -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' @@ -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: | @@ -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 @@ -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" @@ -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 @@ -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") diff --git a/test/run-tests.sh b/test/run-tests.sh new file mode 100755 index 0000000..9bd476d --- /dev/null +++ b/test/run-tests.sh @@ -0,0 +1,250 @@ +#!/usr/bin/env bash +# Runs the action's verify script against stubbed `gh` and `curl`. +# +# Each case sets the GitHub context the action would see, the pull requests +# GitHub reports for the commit, and the PP verify response, then asserts on +# the exit code, the outputs, and the request body sent to PP. +# +# Usage: test/run-tests.sh +set -uo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +WORK="$(mktemp -d)" +trap 'rm -rf "$WORK"' EXIT + +# The script is the `run: |` block of the only step, dedented. +SCRIPT="$WORK/verify.sh" +awk ' + /^ run: \|$/ { inrun = 1; next } + inrun { sub(/^ /, ""); print } +' "$ROOT/action.yml" > "$SCRIPT" +if ! grep -q 'receipts/verify' "$SCRIPT"; then + echo "could not extract the run block from action.yml" >&2 + exit 1 +fi +# Expressions the runner would substitute inside the script body. +sed -i.bak 's/\${{ github.repository }}/acme\/web/g' "$SCRIPT" +# macOS ships bash 3.2; CI runs the script unmodified on bash 5. +if [ "${BASH_VERSINFO[0]}" -lt 4 ]; then + sed -i.bak 's/\${PP_REPOSITORY,,}/$(echo "$PP_REPOSITORY" | tr "[:upper:]" "[:lower:]")/' "$SCRIPT" +fi + +mkdir -p "$WORK/bin" +cat > "$WORK/bin/gh" <<'STUB' +#!/usr/bin/env bash +echo "gh $*" >> "$STUB_DIR/gh.log" +if [ "$1" = "api" ] && [[ "$2" == repos/*/commits/*/pulls ]]; then + if [ -n "${STUB_PULLS_FAIL:-}" ]; then echo "HTTP 403: Resource not accessible by integration" >&2; exit 1; fi + cat "$STUB_DIR/pulls.json" + exit 0 +fi +if [ "$1" = "pr" ] && [ "$2" = "view" ]; then + case "$*" in + *isDraft*) echo "${STUB_IS_DRAFT:-false}" ;; + *files*) printf 'src/app.ts\ndeploy/prod.yml\n' ;; + esac + exit 0 +fi +echo "unexpected gh call: $*" >&2 +exit 1 +STUB +cat > "$WORK/bin/curl" <<'STUB' +#!/usr/bin/env bash +echo "curl $*" >> "$STUB_DIR/curl.log" +while [ $# -gt 0 ]; do + if [ "$1" = "-d" ]; then printf '%s' "$2" > "$STUB_DIR/request.json"; fi + shift +done +cat "$STUB_DIR/response.json" +printf '\n%s' "${STUB_STATUS:-200}" +STUB +chmod +x "$WORK/bin/gh" "$WORK/bin/curl" + +PASS=0 +FAIL=0 +CASE="" +CASE_DIR="" +EXIT_CODE=0 + +# run_case [VAR=value ...]; stdin is the PP response body. +run_case() { + CASE="$1"; shift + CASE_DIR="$WORK/case-$((PASS + FAIL + 1))" + mkdir -p "$CASE_DIR" + cat > "$CASE_DIR/response.json" + [ -f "$CASE_DIR/pulls.json" ] || echo '[]' > "$CASE_DIR/pulls.json" + if [ -n "${PULLS:-}" ]; then printf '%s' "$PULLS" > "$CASE_DIR/pulls.json"; fi + : > "$CASE_DIR/outputs" + ( + export PATH="$WORK/bin:$PATH" STUB_DIR="$CASE_DIR" GITHUB_OUTPUT="$CASE_DIR/outputs" + export PP_BASE_URL=https://pp.test PP_API_KEY=pp_live_test PP_REQUEST_CREATE_TOKEN= + export PP_ENVIRONMENT=production PP_CAPABILITY=deploy:production + export PP_REDEEM= PP_FAIL_ON_MISSING=true PP_PROTECTED_PATHS='^(deploy/|\.github/workflows/)' + export PP_FAIL_OPEN_TIMEOUT=30 PP_FAIL_MODE=closed PP_PRODUCTION_ENVIRONMENTS=production,prod,live + export PP_POST_COMMENT=false PP_REPOSITORY=Acme/Web + export PP_PR_NUMBER= PP_PR_HEAD_SHA= PP_PR_BASE_SHA= + export PP_MODE=auto PP_EVENT_NAME=push PP_SHA=mergesha0000000000 PP_REF=refs/heads/main + export PP_RUN_ID=4242 GH_TOKEN=ghs_test + for assignment in "$@"; do export "$assignment"; done + bash "$SCRIPT" + ) > "$CASE_DIR/stdout" 2>&1 + EXIT_CODE=$? + PULLS="" +} + +output() { # value of a GITHUB_OUTPUT key; like the runner, the last write wins + awk -v key="$1" ' + $0 == key "<<__PP_EOF__" { grab = 1; value = ""; first = 1; next } + grab && $0 == "__PP_EOF__" { grab = 0; next } + grab { value = first ? $0 : value "\n" $0; first = 0 } + END { print value } + ' "$CASE_DIR/outputs" +} +request() { jq -c "$1" "$CASE_DIR/request.json" 2>/dev/null; } + +check() { # check + if [ "$2" = "$3" ]; then + PASS=$((PASS + 1)) + else + FAIL=$((FAIL + 1)) + echo "FAIL [$CASE] $1: expected '$3', got '$2'" + sed 's/^/ | /' "$CASE_DIR/stdout" | tail -20 + fi +} +no_request() { if [ -f "$CASE_DIR/request.json" ]; then echo sent; else echo none; fi; } + +MERGED='[ + {"number": 7, "merged_at": null, "merge_commit_sha": "othersha", "base": {"ref": "main", "sha": "b0"}, "head": {"sha": "unmerged"}}, + {"number": 12, "merged_at": "2026-09-26T10:00:00Z", "merge_commit_sha": "mergesha0000000000", "base": {"ref": "main", "sha": "base12"}, "head": {"sha": "approvedhead12"}} +]' +VALID='{"valid": true, "receiptId": "rcpt_1", "decision": "APPROVED", "requestId": "req_1"}' + +# --- deploy mode: the merged PR's approval is verified and redeemed --------- +PULLS="$MERGED" run_case "push deploys merged PR" <<<"$VALID" +check "exit" "$EXIT_CODE" 0 +check "mode" "$(output mode)" deploy +check "approved" "$(output approved)" true +check "pr-number" "$(output pr-number)" 12 +check "pr-head-sha" "$(output pr-head-sha)" approvedhead12 +check "scope" "$(request .scope)" '{"repo":"acme/web","prNumber":12,"headSha":"approvedhead12","env":"production","capability":"deploy:production"}' +check "redeem" "$(request .redeem)" true +check "failOnMissing" "$(request .failOnMissing)" true +check "no draft lookup" "$(grep -c isDraft "$CASE_DIR/gh.log")" 0 + +PULLS="$MERGED" run_case "workflow_dispatch deploys merged PR" PP_EVENT_NAME=workflow_dispatch <<<"$VALID" +check "exit" "$EXIT_CODE" 0 +check "pr-number" "$(output pr-number)" 12 +check "redeem" "$(request .redeem)" true + +PULLS="$MERGED" run_case "explicit deploy mode with redeem true" PP_MODE=deploy PP_REDEEM=true <<<"$VALID" +check "exit" "$EXIT_CODE" 0 +check "redeem" "$(request .redeem)" true + +# --- deploy mode fails closed ----------------------------------------------- +PULLS='[]' run_case "direct push has no PR" <<<"$VALID" +check "exit" "$EXIT_CODE" 1 +check "error" "$(output error-code)" PP_NO_MERGED_PR +check "approved" "$(output approved)" false +check "no PP call" "$(no_request)" none + +PULLS='[{"number": 3, "merged_at": "2026-09-26T10:00:00Z", "merge_commit_sha": "mergesha0000000000", "base": {"ref": "release", "sha": "b"}, "head": {"sha": "h"}}]' \ + run_case "PR merged into another branch" <<<"$VALID" +check "exit" "$EXIT_CODE" 1 +check "error" "$(output error-code)" PP_NO_MERGED_PR + +PULLS='[{"number": 4, "merged_at": "2026-09-26T10:00:00Z", "merge_commit_sha": "someothersha", "base": {"ref": "main", "sha": "b"}, "head": {"sha": "h"}}]' \ + run_case "commit only contained in a PR, not merged by it" <<<"$VALID" +check "exit" "$EXIT_CODE" 1 +check "error" "$(output error-code)" PP_NO_MERGED_PR + +run_case "PR lookup fails" STUB_PULLS_FAIL=1 <<<"$VALID" +check "exit" "$EXIT_CODE" 1 +check "error" "$(output error-code)" PP_PR_LOOKUP_FAILED +check "no PP call" "$(no_request)" none + +PULLS='{"message": "Not Found"}' run_case "PR lookup returns an object" <<<"$VALID" +check "exit" "$EXIT_CODE" 1 +check "error" "$(output error-code)" PP_PR_LOOKUP_FAILED +check "no PP call" "$(no_request)" none + +PULLS="$MERGED" run_case "deploy mode rejects redeem false" PP_MODE=deploy PP_REDEEM=false <<<"$VALID" +check "exit" "$EXIT_CODE" 1 +check "error" "$(output error-code)" PP_INVALID_CONFIG +check "no PP call" "$(no_request)" none + +PULLS="$MERGED" run_case "deploy mode rejects fail-on-missing false" PP_FAIL_ON_MISSING=false <<<"$VALID" +check "exit" "$EXIT_CODE" 1 +check "error" "$(output error-code)" PP_INVALID_CONFIG + +run_case "deploy mode refuses pull_request events" PP_MODE=deploy PP_EVENT_NAME=pull_request PP_PR_NUMBER=12 PP_PR_HEAD_SHA=h <<<"$VALID" +check "exit" "$EXIT_CODE" 1 +check "error" "$(output error-code)" PP_UNSUPPORTED_EVENT + +run_case "deploy mode refuses tags" PP_REF=refs/tags/v1.0.0 <<<"$VALID" +check "exit" "$EXIT_CODE" 1 +check "error" "$(output error-code)" PP_UNSUPPORTED_REF + +run_case "auto mode refuses other events" PP_EVENT_NAME=schedule <<<"$VALID" +check "exit" "$EXIT_CODE" 1 +check "error" "$(output error-code)" PP_UNSUPPORTED_EVENT + +run_case "invalid mode" PP_MODE=yolo <<<"$VALID" +check "exit" "$EXIT_CODE" 1 +check "error" "$(output error-code)" PP_INVALID_MODE + +PULLS="$MERGED" run_case "no receipt for the merged PR" <<<'{"valid": false, "errorCode": "RECEIPT_NOT_FOUND", "requestId": "req_9"}' +check "exit" "$EXIT_CODE" 1 +check "approved" "$(output approved)" false +check "approval-url" "$(output approval-url)" https://pp.test/pp/deploy-requests/req_9 + +PULLS="$MERGED" run_case "receipt already redeemed" <<<'{"valid": false, "errorCode": "RECEIPT_ALREADY_REDEEMED"}' +check "exit" "$EXIT_CODE" 1 +check "error" "$(output error-code)" RECEIPT_ALREADY_REDEEMED + +PULLS="$MERGED" run_case "receipt expired" <<<'{"valid": false, "errorCode": "RECEIPT_EXPIRED", "requestId": "req_3"}' +check "exit" "$EXIT_CODE" 1 +check "error" "$(output error-code)" RECEIPT_EXPIRED + +PULLS="$MERGED" run_case "expired, signer can renew" <<<'{"result": {"valid": false, "errorCode": "RECEIPT_EXPIRED"}, "reapproval": {"autoRenewEligible": true, "requestId": "req_3", "approvalUrl": "https://pp.test/approve/req_3"}}' +check "exit" "$EXIT_CODE" 1 +check "error" "$(output error-code)" RECEIPT_EXPIRED +check "renew link" "$(output approval-url)" https://pp.test/approve/req_3 + +PULLS="$MERGED" run_case "expired, renewal refused" <<<'{"result": {"valid": false, "errorCode": "RECEIPT_EXPIRED"}, "reapproval": {"autoRenewEligible": false, "approvalUrl": "https://pp.test/approve/req_3", "reason": "This request is already merged"}}' +check "exit" "$EXIT_CODE" 1 +check "no renew link" "$(output approval-url)" "" +check "reason shown" "$(grep -c 'already merged' "$CASE_DIR/stdout")" 1 + +PULLS="$MERGED" run_case "PP unavailable in production" STUB_STATUS=000 <<<'' +check "exit" "$EXIT_CODE" 1 +check "error" "$(output error-code)" PP_UNAVAILABLE + +# --- pr mode is unchanged --------------------------------------------------- +PR_CTX=(PP_EVENT_NAME=pull_request PP_PR_NUMBER=12 PP_PR_HEAD_SHA=approvedhead12 PP_PR_BASE_SHA=base12) + +run_case "pull_request gate" "${PR_CTX[@]}" <<<"$VALID" +check "exit" "$EXIT_CODE" 0 +check "mode" "$(output mode)" pr +check "scope" "$(request .scope)" '{"repo":"acme/web","prNumber":12,"headSha":"approvedhead12","env":"production","capability":"deploy:production"}' +check "redeem defaults off" "$(request .redeem)" false +check "no commit lookup" "$(grep -c '/pulls' "$CASE_DIR/gh.log")" 0 + +run_case "pull_request gate with redeem true" "${PR_CTX[@]}" PP_REDEEM=true <<<"$VALID" +check "redeem" "$(request .redeem)" true + +run_case "draft PR skipped" "${PR_CTX[@]}" STUB_IS_DRAFT=true <<<"$VALID" +check "exit" "$EXIT_CODE" 0 +check "decision" "$(output decision)" DRAFT_SKIPPED +check "no PP call" "$(no_request)" none + +run_case "pr mode without a PR" PP_MODE=pr <<<"$VALID" +check "exit" "$EXIT_CODE" 1 +check "error" "$(output error-code)" PP_NO_PR_CONTEXT +check "no PP call" "$(no_request)" none + +run_case "pr gate missing receipt, fail-on-missing false" "${PR_CTX[@]}" PP_FAIL_ON_MISSING=false <<<'{"valid": false, "errorCode": "RECEIPT_NOT_FOUND"}' +check "exit" "$EXIT_CODE" 0 + +echo "" +echo "${PASS} passed, ${FAIL} failed" +[ "$FAIL" -eq 0 ]