Skip to content

Deploy mode: redeem the merged PR's approval on push (v2.3.0) - #69

Merged
rodchalski merged 1 commit into
mainfrom
feat/deploy-mode
Sep 28, 2026
Merged

rodchalski merged 1 commit into
mainfrom
feat/deploy-mode

Conversation

@rodchalski

Copy link
Copy Markdown
Collaborator

What

Deploy Gate can now gate the deploy, not just the merge. On push / workflow_dispatch it:

  1. Finds the PR that merged the pushed commit: GET /repos/{repo}/commits/{sha}/pulls, keeping only merged PRs whose merge_commit_sha is the pushed SHA and whose base is the pushed branch.
  2. Verifies the receipt for that PR's head SHA, with the same scope (repo, prNumber, headSha, env, capability) the PR gate used.
  3. Redeems it, so one approval ships once.

This is the "protected CI workflow" the PP app tells Vercel, Netlify, Railway and Fly.io users to set up; until now none worked. Running the action on push crashed: PP_PR_NUMBER is empty outside pull_request, and jq --argjson prNumber "" errors.

Fails closed

Case Error
Direct push, or a commit no PR merged into this branch PP_NO_MERGED_PR
PR lookup fails (e.g. missing pull-requests: read), or the response is unreadable PP_PR_LOOKUP_FAILED
mode: deploy on a pull_request event PP_UNSUPPORTED_EVENT
Tag ref PP_UNSUPPORTED_REF
redeem: false or fail-on-missing: false in deploy mode PP_INVALID_CONFIG
mode: pr without a PR PP_NO_PR_CONTEXT (previously a jq crash)

None of these call PP. Receipt errors (RECEIPT_NOT_FOUND, RECEIPT_ALREADY_REDEEMED, RECEIPT_EXPIRED) keep their existing handling, with deploy-specific messages. RECEIPT_EXPIRED prints reapproval.approvalUrl when PP reports autoRenewEligible.

Compatibility

  • mode defaults to auto. Existing pull_request workflows run in pr mode, which behaves as before.
  • redeem now defaults to '', which means false in pr mode (same as the old default) and true in deploy mode. Explicit redeem: true in a PR gate is unchanged.
  • Push workflows were broken before, so none of them lose working behavior.

Known limit (server side, not this PR)

Production approvals expire 15 minutes after signing, and PP refuses to renew an approval once the PR is merged. A PR merged more than 15 minutes after approval therefore fails deploy with RECEIPT_EXPIRED and has no renew path. Auto-merge avoids this. The fix belongs in the PP app's re-approval rules. INSTALL.md states the limit.

Tests

test/run-tests.sh extracts the action's run: block and runs it against stubbed gh and curl, asserting exit codes, outputs and the exact request body sent to PP. It runs in the new Test workflow.

  • Locally: 69 passed, 0 failed.
  • Mutation check: dropping the merge_commit_sha match, or turning off redeem in deploy mode, makes the suite fail.

Release

Not tagged. After merge: tag v2.3.0 and move v2. Note that v2 currently points at v2.1.0 (fbf265b), so moving it also ships v2.2.0's draft-PR skip to every @v2 user.

🤖 Generated with Claude Code

On push and workflow_dispatch the action now finds the pull request that
merged the commit being deployed (merge_commit_sha on the pushed branch),
verifies the receipt a signer approved for that PR's head SHA, and redeems
it once. This lets a Vercel, Netlify, Railway or Fly.io deploy workflow sit
behind the same approval as the PR gate.

Before this, running the action on push crashed: the PR number is empty
outside pull_request events and `jq --argjson prNumber ""` failed.

- mode: auto | pr | deploy (auto picks pr on pull_request, deploy on push
  and workflow_dispatch, and errors on anything else)
- deploy mode fails closed: no merged PR (direct push), PR merged into
  another branch, PR lookup failure, tags, pull_request events,
  redeem: false, fail-on-missing: false
- RECEIPT_EXPIRED prints the renew link when PP says a signer can renew
- new outputs: mode, pr-number, pr-head-sha
- test/run-tests.sh runs the action script against stubbed gh and curl
  (69 checks), wired into a Test workflow

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Sep 26, 2026 •

Copy link
Copy Markdown

✅ Permission Protocol: Approved
View receipt →

@permission-protocol

permission-protocol Bot commented Sep 28, 2026 •

Copy link
Copy Markdown

🔒 Permission Protocol

✅ Approved — human authorization recorded. View receipt
Request: 40e44e27-bb10-45cb-b0d8-c80181ba9641 · Commit: 1bcb653


Approval brief

Affected flow: Production deployment gating on push or workflow_dispatch events.

Why this change: The action previously crashed on push events because it lacked PR context; it now supports gating the actual deploy by redeeming merged PR approvals.

What changes for users/operators: Users can now gate deployments on push to main. The action finds the merged PR, verifies its approval receipt, and redeems it to ensure one approval ships once.

Blast radius:

  • Affected: Build, routing, or release behavior
  • Not indicated: auth, database, billing, secrets

⚠️ What this change risks

This change modifies the core CI/CD gating logic and introduces infrastructure-level automation scripts, which are high-risk if they incorrectly block the release pipeline.

  • Risk: 🔴 High — CI/CD
  • Impact: If incorrect, deployments on push will fail with errors like PP_NO_MERGED_PR or PP_PR_LOOKUP_FAILED, blocking production releases even for approved code.
  • Files: .github/workflows/test.yml, action.yml, test/run-tests.sh, INSTALL.md, +1 more
  • Status: ✅ Approved — receipt

✅ View authorization receipt →

Authorization recorded; use the checks below as merge/deploy verification evidence.


✅ Verify before authorizing
  • Confirm the action.yml 'mode' input defaults to 'auto' to support both PR and push events.
  • Verify the script correctly handles the 'pull-requests: read' permission requirement for PR lookup.
  • Check that 'redeem: false' is explicitly rejected in deploy mode to prevent unredeemed shipping.
📋 Why was this flagged?
  • The action now supports a 'deploy' mode that finds and redeems merged PR approvals on push events.
  • Running the action on push previously crashed due to empty PR numbers and jq errors.
  • The change introduces specific error codes like PP_NO_MERGED_PR and PP_PR_LOOKUP_FAILED for failure cases.
  • A new test suite and CI workflow were added to verify the action script logic.

🔒 Permission Protocol · Decision: approved with human authorization

@rodchalski
rodchalski merged commit 53ccb94 into main Sep 28, 2026
3 of 4 checks passed
@rodchalski
rodchalski deleted the feat/deploy-mode branch September 28, 2026 18:29
rodchalski pushed a commit that referenced this pull request Sep 29, 2026
PP rejects a prTitle longer than 300 UTF-16 units. The first version cut at
300 code points, and an emoji counts as two units, so a long emoji-heavy
title could exceed the limit and fail the gate closed with a validation
error. The title is now cut to 300 code points when every code point is a
single unit, else to 150 (at most two units each).

test/run-tests.sh (from #69) now covers the title: quotes, a newline and a
$(...) round-trip as a JSON string without being run; an empty title omits
the key; a long ASCII title is cut to 300; a 200-emoji title is cut to 150
code points, 300 UTF-16 units; a push-mode deploy sends no title. Reverting
to the code-point cap fails the emoji case. 77 passed locally (bash 3.2,
jq 1.7.1).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
rodchalski added a commit that referenced this pull request Sep 29, 2026
* Send the PR title with the verify request

The approval page shows a request's title from the request row when PP
cannot read the pull request itself (no GitHub App access to the repository
in that workspace). The verify route has accepted an optional prTitle for a
while; this action never sent one, so those pages fell back to "Authorize
production deploy" with no description, even for a PR with a full title.

The title reaches the script through env, never interpolated into it, since
a PR title is attacker-controlled text, and reaches jq through --arg. PP
accepts at most 300 characters and rejects null, so the value is trimmed to
300 and the key is left out when the title is empty.

Checked locally with jq: a title with quotes and a newline round-trips as a
JSON string; an empty title omits the key; a 400-character title is sent as
300.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* Cap the PR title by UTF-16 length; test the title in the harness

PP rejects a prTitle longer than 300 UTF-16 units. The first version cut at
300 code points, and an emoji counts as two units, so a long emoji-heavy
title could exceed the limit and fail the gate closed with a validation
error. The title is now cut to 300 code points when every code point is a
single unit, else to 150 (at most two units each).

test/run-tests.sh (from #69) now covers the title: quotes, a newline and a
$(...) round-trip as a JSON string without being run; an empty title omits
the key; a long ASCII title is cut to 300; a 200-emoji title is cut to 150
code points, 300 UTF-16 units; a push-mode deploy sends no title. Reverting
to the code-point cap fails the emoji case. 77 passed locally (bash 3.2,
jq 1.7.1).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Rod <rod@Rods-MacBook-Air.local>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant