Skip to content

ci: approve release bump PR via a least-privilege GitHub App - #32

Merged
helly25 merged 1 commit into
mainfrom
ci/release-app-approval
Jun 4, 2026
Merged

ci: approve release bump PR via a least-privilege GitHub App#32
helly25 merged 1 commit into
mainfrom
ci/release-app-approval

Conversation

@helly25

@helly25 helly25 commented Jun 4, 2026

Copy link
Copy Markdown
Owner

Problem

trigger_release.sh tried to approve and admin-merge its own version-bump PR with one identity. GitHub rejects self-approval, and the main ruleset blocks it anyway: require_last_push_approval: true, required_approving_review_count: 1, and bypass_actors: [] / can_bypass: never (so --admin can never override). The result was a pushed release tag plus a stuck, unmergeable PR.

Approach — a dedicated GitHub App approves (no bypass)

The App's only power is pull_requests:write (+ metadata:read). It satisfies the required review as a second identity rather than bypassing anything, so main keeps full protection and no human or token holds a standing override. (Chosen over a bypass_actors entry precisely to avoid any standing override existing.)

  • Admin gate — only a repo admin may run a release (checked via their own gh auth first).
  • Config from Actions variablesRELEASE_APP_ID, RELEASE_APP_INSTALLATION_ID, RELEASE_APP_KEY_SERVICE are read from the repo's Actions variables (env overrides), so nothing is hard-coded and the script is reusable.
  • Identity split — create/push/merge run as the human; a short-lived App installation token (RS256 JWT via openssl) authenticates only the approval, so approver ≠ last pusher.
  • Fail-fast preflight — all config is validated and an installation token is minted before any irreversible step, so a misconfig can't strand a pushed tag.
  • --dry — runs every check (incl. the App preflight) and prints the plan, changing nothing.
  • The PEM is stored raw in the Keychain; the script uses it verbatim or hex-decodes it on read (macOS security -w hex-encodes any secret containing newlines).

Verified

./tools/trigger_release.sh --dry 0.4.3 reaches App credential preflight: ok (installation token mints). — token mints, permissions are exactly {"metadata":"read","pull_requests":"write"}, installed on helly25/bzl only — then correctly stops at the pre-existing "clean main" guard (run from a feature branch).

Not yet proven (only observable on a real PR): that the App's approval counts toward the required review. First real release — or a throwaway PR — confirms it; fallback is a bypass_actors entry for the App.

Test plan

  • shellcheck + beautysh clean.
  • App-credential preflight succeeds (token mint, scoped perms, repo install).
  • ./tools/trigger_release.sh --dry <version> reaches [dry-run] All checks passed from a clean main after merge.

🤖 Generated with Claude Code

The release script previously tried to approve and admin-merge its own
version-bump PR with a single identity, which GitHub rejects (self-approval)
and the Main ruleset blocks (require_last_push_approval, no bypass actors).

Rework trigger_release.sh so a dedicated GitHub App approves the PR:

- Admin gate: only a repo admin may run a release (checked via their own gh
  auth before anything else).
- Config (RELEASE_APP_ID / RELEASE_APP_INSTALLATION_ID / RELEASE_APP_KEY_SERVICE)
  is read from this repo's Actions variables, so nothing is hard-coded and the
  script is reusable; a same-named env var overrides.
- The App only approves (permissions: pull_requests:write + metadata:read) -- it
  cannot bypass branch protection or merge, so main keeps full protection and no
  human or token holds a standing override. Its approval *satisfies* the rule.
- A short-lived installation token (RS256 JWT via openssl) authenticates ONLY
  the approval step; create/push/merge run as the human, so the App (approver)
  differs from the human (last pusher).
- Fail-fast: all config is validated and an installation token is preflight-
  minted BEFORE any irreversible step, so a misconfig can't leave a pushed tag
  with an unmergeable PR.
- New --dry mode runs every check (incl. the App preflight) and prints the plan
  without changing anything.

The PEM is stored raw in the Keychain; the script reads it back tolerating
macOS `security -w`, which hex-encodes any secret containing newlines, so the
value is used verbatim if it is already a PEM and hex-decoded otherwise.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@helly25
helly25 force-pushed the ci/release-app-approval branch from a773b31 to 3bee431 Compare June 4, 2026 22:33
@helly25
helly25 requested a review from Fab-Cat June 4, 2026 22:34
@helly25
helly25 merged commit 754f00a into main Jun 4, 2026
11 checks passed
@helly25
helly25 deleted the ci/release-app-approval branch June 4, 2026 22:38
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