Merge pull request #26 from pezkuwichain/ci/extend-deploy-approval-window

ci: gate deploys on a GitHub Environment instead of a hand-rolled wait loop
This commit is contained in:
SatoshiQaziMuhammed
2026-07-31 07:27:48 -07:00
committed by GitHub
3 changed files with 245 additions and 47 deletions
+35 -47
View File
@@ -153,7 +153,7 @@ jobs:
build-image: build-image:
name: Build & Push Image name: Build & Push Image
runs-on: pwap-runner runs-on: pwap-runner
needs: [web, telegram-gate] needs: [web, notify-deploy-pending]
if: | if: |
github.ref == 'refs/heads/main' && github.ref == 'refs/heads/main' &&
(github.event_name == 'push' || (github.event_name == 'push' ||
@@ -235,7 +235,7 @@ jobs:
build-image-backend: build-image-backend:
name: Build & Push Backend Image name: Build & Push Backend Image
runs-on: pwap-runner runs-on: pwap-runner
needs: [backend, telegram-gate] needs: [backend, notify-deploy-pending]
if: | if: |
(github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/')) && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/')) &&
(github.event_name == 'push' || (github.event_name == 'push' ||
@@ -298,31 +298,35 @@ jobs:
echo "✅ Backend image signed (transparency log: rekor.sigstore.dev)" echo "✅ Backend image signed (transparency log: rekor.sigstore.dev)"
# ======================================== # ========================================
# TELEGRAM CEO APPROVAL GATE # DEPLOY APPROVAL
# Runs on self-hosted pwap-runner (DEV VPS) where pexsec-bot.service # The approval is a GitHub Environment protection rule on `production`, not
# writes the gate file to /tmp/pexsec-gates/<sha> when CEO clicks # something implemented here. No runner is held while it waits, the window is
# Approve/Cancel in Telegram. 30-minute timeout = deploy cancelled. # 30 days instead of minutes, and who approved which SHA is recorded in the
# deployment history.
#
# This job only notifies; it sends one message and exits in seconds and cannot
# block anything. It replaces a loop that polled /tmp/pexsec-gates for 30
# minutes while occupying a pwap-runner slot — on 2026-07-30 that window
# expired unseen and cancelled a deploy with nothing shipped.
# ======================================== # ========================================
telegram-gate: notify-deploy-pending:
name: Telegram deploy approval name: Notify approver
runs-on: pwap-runner runs-on: pwap-runner
needs: [web, security-audit] needs: [web, security-audit]
if: github.ref == 'refs/heads/main' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') if: github.ref == 'refs/heads/main' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch')
timeout-minutes: 5
steps: steps:
- name: Send approval request and wait - name: Send Telegram notification
env: env:
BOT_TOKEN: ${{ secrets.PEXSEC_BOT_TOKEN }} BOT_TOKEN: ${{ secrets.PEXSEC_BOT_TOKEN }}
CEO_CHAT_ID: ${{ secrets.TELEGRAM_CEO_CHAT_ID }} CEO_CHAT_ID: ${{ secrets.TELEGRAM_CEO_CHAT_ID }}
SHA: ${{ github.sha }} SHA: ${{ github.sha }}
ACTOR: ${{ github.actor }} ACTOR: ${{ github.actor }}
MESSAGE: ${{ github.event.head_commit.message }} MESSAGE: ${{ github.event.head_commit.message }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: | run: |
SHORT="${SHA:0:7}" SHORT="${SHA:0:7}"
GATE_DIR="/tmp/pexsec-gates"
mkdir -p "$GATE_DIR" 2>/dev/null || true
rm -f "$GATE_DIR/$SHORT" 2>/dev/null || true
# Strip Markdown special chars to prevent Telegram parse errors # Strip Markdown special chars to prevent Telegram parse errors
SAFE_MSG=$(echo "${MESSAGE}" | head -1 | tr -d '_*`[]()#|{}!' | cut -c1-120) SAFE_MSG=$(echo "${MESSAGE}" | head -1 | tr -d '_*`[]()#|{}!' | cut -c1-120)
@@ -331,35 +335,15 @@ jobs:
-d "{ -d "{
\"chat_id\": \"${CEO_CHAT_ID}\", \"chat_id\": \"${CEO_CHAT_ID}\",
\"parse_mode\": \"Markdown\", \"parse_mode\": \"Markdown\",
\"text\": \"🚀 *pwap/web Deploy Approval*\\n\\n\`${SHORT}\` — ${ACTOR}\\n\\n_${SAFE_MSG}_\\n\\nTargets: app.pezkuwichain.io + pex.mom\", \"text\": \"🚀 *pwap/web Deploy Approval*\\n\\n\`${SHORT}\` — ${ACTOR}\\n\\n_${SAFE_MSG}_\\n\\nTargets: app.pezkuwichain.io + pex.mom\\n\\nApprove in GitHub — the deploy waits until you do.\",
\"reply_markup\": { \"reply_markup\": {
\"inline_keyboard\": [[ \"inline_keyboard\": [[
{\"text\": \" Approve\", \"callback_data\": \"deploy_approve:${SHORT}\"}, {\"text\": \"🔎 Review \& Approve\", \"url\": \"${RUN_URL}\"}
{\"text\": \"❌ Cancel\", \"callback_data\": \"deploy_cancel:${SHORT}\"}
]] ]]
} }
}" }" > /dev/null
echo "Waiting for Telegram approval (max 30 min)..." echo "Approver notified; deployment waits on the 'production' environment."
TIMEOUT=1800
ELAPSED=0
while [ $ELAPSED -lt $TIMEOUT ]; do
if [ -f "$GATE_DIR/$SHORT" ]; then
DECISION=$(cat "$GATE_DIR/$SHORT")
rm -f "$GATE_DIR/$SHORT" 2>/dev/null || true
if [ "$DECISION" = "approved" ]; then
echo "Deploy approved."
exit 0
else
echo "Deploy cancelled."
exit 1
fi
fi
sleep 10
ELAPSED=$((ELAPSED + 10))
done
echo "No approval received within 30 minutes — deploy cancelled."
exit 1
# ======================================== # ========================================
# VERSION BUMP (RUNS BEFORE BOTH DEPLOYS) # VERSION BUMP (RUNS BEFORE BOTH DEPLOYS)
@@ -367,7 +351,7 @@ jobs:
bump-version: bump-version:
name: Bump Version name: Bump Version
runs-on: pwap-runner runs-on: pwap-runner
needs: [web, security-audit, telegram-gate, build-image] needs: [web, security-audit, notify-deploy-pending, build-image]
# Skip on rollback (workflow_dispatch with rollback_to set) # Skip on rollback (workflow_dispatch with rollback_to set)
if: | if: |
github.ref == 'refs/heads/main' && github.ref == 'refs/heads/main' &&
@@ -413,10 +397,11 @@ jobs:
deploy-app: deploy-app:
name: Deploy app.pezkuwichain.io name: Deploy app.pezkuwichain.io
runs-on: pwap-runner runs-on: pwap-runner
needs: [telegram-gate, bump-version, build-image] environment: production
needs: [notify-deploy-pending, bump-version, build-image]
if: | if: |
always() && always() &&
needs.telegram-gate.result == 'success' && needs.notify-deploy-pending.result == 'success' &&
((github.event_name == 'push' && needs.build-image.result == 'success' && needs.bump-version.result == 'success') || ((github.event_name == 'push' && needs.build-image.result == 'success' && needs.bump-version.result == 'success') ||
(github.event_name == 'workflow_dispatch' && github.event.inputs.rollback_to != '')) (github.event_name == 'workflow_dispatch' && github.event.inputs.rollback_to != ''))
permissions: permissions:
@@ -582,10 +567,11 @@ jobs:
deploy-pex: deploy-pex:
name: Deploy pex.mom name: Deploy pex.mom
runs-on: pwap-runner runs-on: pwap-runner
needs: [telegram-gate, bump-version, build-image] environment: production
needs: [notify-deploy-pending, bump-version, build-image]
if: | if: |
always() && always() &&
needs.telegram-gate.result == 'success' && needs.notify-deploy-pending.result == 'success' &&
((github.event_name == 'push' && needs.build-image.result == 'success' && needs.bump-version.result == 'success') || ((github.event_name == 'push' && needs.build-image.result == 'success' && needs.bump-version.result == 'success') ||
(github.event_name == 'workflow_dispatch' && github.event.inputs.rollback_to != '')) (github.event_name == 'workflow_dispatch' && github.event.inputs.rollback_to != ''))
permissions: permissions:
@@ -744,7 +730,7 @@ jobs:
# Runs a container from the SHA-tagged GHCR image on the backend VPS. # Runs a container from the SHA-tagged GHCR image on the backend VPS.
# Same discipline as the web deploy: cosign verify → deploy → health check → # Same discipline as the web deploy: cosign verify → deploy → health check →
# auto-rollback to previous SHA → Telegram notify. Gated behind the SAME # auto-rollback to previous SHA → Telegram notify. Gated behind the SAME
# telegram-gate approval as the web deploy. Only main/tags, never fork PRs. # same environment approval as the web deploy. Only main/tags, never fork PRs.
# #
# STATEFUL DB: the sqlite file lives in the named docker volume # STATEFUL DB: the sqlite file lives in the named docker volume
# `pwap-indexer-db` (mounted at /data). Deploy replaces the container/image # `pwap-indexer-db` (mounted at /data). Deploy replaces the container/image
@@ -777,12 +763,13 @@ jobs:
deploy-backend: deploy-backend:
name: Deploy Backend (indexer) name: Deploy Backend (indexer)
runs-on: pwap-runner runs-on: pwap-runner
needs: [telegram-gate, build-image-backend, backend-cfg] environment: production
needs: [notify-deploy-pending, build-image-backend, backend-cfg]
if: | if: |
always() && always() &&
needs.backend-cfg.outputs.configured == 'true' && needs.backend-cfg.outputs.configured == 'true' &&
(github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/')) && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/')) &&
needs.telegram-gate.result == 'success' && needs.notify-deploy-pending.result == 'success' &&
((github.event_name == 'push' && needs.build-image-backend.result == 'success') || ((github.event_name == 'push' && needs.build-image-backend.result == 'success') ||
(github.event_name == 'workflow_dispatch' && github.event.inputs.rollback_to != '')) (github.event_name == 'workflow_dispatch' && github.event.inputs.rollback_to != ''))
permissions: permissions:
@@ -941,10 +928,11 @@ jobs:
deploy-supabase: deploy-supabase:
name: Deploy Supabase (functions + migrations) name: Deploy Supabase (functions + migrations)
runs-on: pwap-runner runs-on: pwap-runner
needs: [telegram-gate] environment: production
needs: [notify-deploy-pending]
if: | if: |
always() && always() &&
needs.telegram-gate.result == 'success' && needs.notify-deploy-pending.result == 'success' &&
github.event_name == 'push' && github.ref == 'refs/heads/main' github.event_name == 'push' && github.ref == 'refs/heads/main'
permissions: permissions:
contents: read contents: read
+75
View File
@@ -0,0 +1,75 @@
# ops
Operational configuration that lives outside the application code.
## `apply-repo-settings.sh`
The repository's GitHub protections, as code.
Branch protection and the `production` environment are configured through the
GitHub API. Nothing in the repository reflects them, so by default they are
invisible here, unversioned, and gone without a trace if someone removes them —
which undercuts the point, since both exist precisely so that nothing reaches
`main` or production unreviewed.
```bash
./ops/apply-repo-settings.sh # apply the desired state
./ops/apply-repo-settings.sh --check # report drift, change nothing
```
Idempotent: each call PUTs the full desired state, so repeated runs converge.
Requires `gh` authenticated with admin rights on the repo.
### What it enforces
**Branch protection on `main`**
| setting | value | why |
|---|---|---|
| required check | `CI Gate ✅` | aggregate job — fails if web, backend or security-audit did not succeed |
| strict | true | branch must be up to date with `main` before merging |
| approvals | 1 | no unreviewed code on `main` |
| dismiss stale reviews | true | an approval does not carry over to new commits |
| force pushes | blocked | history cannot be rewritten |
| deletions | blocked | `main` cannot be deleted |
| conversation resolution | required | review comments cannot be merged past |
Only `CI Gate ✅` is listed as a required check, deliberately. It runs with
`if: always()` and inspects the results of the jobs it needs, so it already
covers them. Listing each job here as well would mean editing this file whenever
one is renamed — and a rename would then quietly drop a requirement instead of
failing loudly.
`enforce_admins` stays `false` on purpose: an admin needs a way through during a
real incident. It is an escape hatch, not the normal path — rare, visible, and
recorded in the branch's protection history.
**`production` environment**
Deployment approval is an environment protection rule, not something the workflow
implements. GitHub holds the run until a reviewer approves it:
- no runner is occupied while waiting
- the window is 30 days, so a missed notification costs nothing
- who approved which SHA is recorded in the deployment history
- approval state survives a runner restart
The deploy jobs in `quality-gate.yml` opt in with `environment: production`.
`notify-deploy-pending` only sends a Telegram message pointing at the run; it
cannot block anything.
This replaced a job that polled `/tmp/pexsec-gates` for 30 minutes while holding
a runner slot. On 2026-07-30 that window expired unseen and cancelled a deploy —
a merged migration never reached the database and the only trace was a failed
job.
### Reviewer
`REVIEWER_ID` is a numeric user id rather than a login, because the API takes ids
and a login rename would silently break the rule instead of erroring.
Override any of it via environment variables:
```bash
REPO=owner/repo BRANCH=main REVIEWER_ID=123456 ./ops/apply-repo-settings.sh
```
+135
View File
@@ -0,0 +1,135 @@
#!/usr/bin/env bash
#
# apply-repo-settings.sh — the repo's GitHub protections, as code.
#
# Branch protection and the production environment are configured through
# GitHub's API, not through anything in this repository. That means they are
# invisible here, unversioned, and gone without trace if someone removes them.
# The whole point of both is that nobody can push to main or deploy unreviewed,
# so leaving them as undocumented clicks in a settings page undercuts them.
#
# This script is the source of truth. It is idempotent — every call is a PUT of
# the full desired state, so running it repeatedly converges rather than stacks.
#
# ./ops/apply-repo-settings.sh # apply
# ./ops/apply-repo-settings.sh --check # report drift, change nothing
#
# Requires gh with admin rights on the repo.
set -euo pipefail
REPO="${REPO:-pezkuwichain/pwap}"
BRANCH="${BRANCH:-main}"
ENVIRONMENT="${ENVIRONMENT:-production}"
# The reviewer who approves deployments. Numeric id rather than login, because
# the API takes ids and a login rename would silently break the rule.
REVIEWER_ID="${REVIEWER_ID:-224622464}" # SatoshiQaziMuhammed
CHECK_ONLY=0
[[ "${1:-}" == "--check" ]] && CHECK_ONLY=1
info() { printf ' %s\n' "$*"; }
ok() { printf ' \033[32m✔\033[0m %s\n' "$*"; }
bad() { printf ' \033[31m✗\033[0m %s\n' "$*"; }
# ── Branch protection ────────────────────────────────────────────────────────
# CI Gate ✅ is the only required check on purpose: it is an aggregate job that
# runs with if:always() and fails if web, backend or security-audit did not
# succeed. Listing the individual jobs here as well would mean this file has to
# be edited every time one is renamed, and a rename would silently drop a
# requirement rather than fail loudly.
read -r -d '' PROTECTION <<'JSON' || true
{
"required_status_checks": { "strict": true, "contexts": ["CI Gate ✅"] },
"enforce_admins": false,
"required_pull_request_reviews": {
"dismiss_stale_reviews": true,
"require_code_owner_reviews": false,
"required_approving_review_count": 1,
"require_last_push_approval": false
},
"restrictions": null,
"allow_force_pushes": false,
"allow_deletions": false,
"required_conversation_resolution": true
}
JSON
# enforce_admins stays false deliberately: an admin needs a way out during a real
# incident. It is an escape hatch, not the normal path — using it should be rare
# and visible, and it is recorded in the branch's protection history.
# ── Deployment approval ──────────────────────────────────────────────────────
# Approval is an environment protection rule, not something the workflow
# implements. GitHub holds the run without occupying a runner, waits up to 30
# days, and records who approved which SHA. The workflow's deploy jobs opt in
# with `environment: production`.
read -r -d '' ENV_CONFIG <<JSON || true
{
"wait_timer": 0,
"prevent_self_review": false,
"reviewers": [{ "type": "User", "id": ${REVIEWER_ID} }],
"deployment_branch_policy": { "protected_branches": true, "custom_branch_policies": false }
}
JSON
echo "▶ repo settings: $REPO"
if [[ $CHECK_ONLY -eq 1 ]]; then
echo "── branch protection ($BRANCH)"
cur="$(gh api "repos/$REPO/branches/$BRANCH/protection" 2>/dev/null || echo '{}')"
if [[ "$cur" == "{}" ]]; then
bad "branch is NOT protected"
else
python3 - "$cur" <<'PY'
import json, sys
d = json.loads(sys.argv[1])
checks = (d.get('required_status_checks') or {})
rev = (d.get('required_pull_request_reviews') or {})
want = {
'required checks': (checks.get('contexts'), ['CI Gate ✅']),
'strict': (checks.get('strict'), True),
'approvals': (rev.get('required_approving_review_count'), 1),
'dismiss stale': (rev.get('dismiss_stale_reviews'), True),
'force pushes blocked': (not (d.get('allow_force_pushes') or {}).get('enabled'), True),
'deletions blocked': (not (d.get('allow_deletions') or {}).get('enabled'), True),
'conversation resolution': ((d.get('required_conversation_resolution') or {}).get('enabled'), True),
}
for label, (got, exp) in want.items():
mark = '\033[32m✔\033[0m' if got == exp else '\033[31m✗\033[0m'
extra = '' if got == exp else f' (expected {exp}, got {got})'
print(f' {mark} {label}{extra}')
PY
fi
echo "── environment ($ENVIRONMENT)"
env_cur="$(gh api "repos/$REPO/environments/$ENVIRONMENT" 2>/dev/null || echo '{}')"
python3 - "$env_cur" <<'PY'
import json, sys
d = json.loads(sys.argv[1])
if not d:
print(' \033[31m✗\033[0m environment does not exist')
raise SystemExit
rules = d.get('protection_rules') or []
reviewers = []
for r in rules:
if r.get('type') == 'required_reviewers':
reviewers = [x['reviewer'].get('login') for x in r.get('reviewers', [])]
mark = '\033[32m✔\033[0m' if reviewers else '\033[31m✗\033[0m'
print(f' {mark} required reviewers: {reviewers or "NONE — deploys are not gated"}')
pol = d.get('deployment_branch_policy') or {}
mark = '\033[32m✔\033[0m' if pol.get('protected_branches') else '\033[31m✗\033[0m'
print(f' {mark} protected branches only: {pol.get("protected_branches")}')
PY
exit 0
fi
echo "── applying branch protection ($BRANCH)"
gh api -X PUT "repos/$REPO/branches/$BRANCH/protection" --input - <<<"$PROTECTION" >/dev/null
ok "protected: 1 approval, CI Gate required, no force push, no deletion"
echo "── applying environment ($ENVIRONMENT)"
gh api -X PUT "repos/$REPO/environments/$ENVIRONMENT" --input - <<<"$ENV_CONFIG" >/dev/null
ok "deployments require review, protected branches only"
echo "✔ done — verify with: $0 --check"