mirror of
https://github.com/pezkuwichain/pwap.git
synced 2026-08-12 03:30:57 +00:00
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:
@@ -153,7 +153,7 @@ jobs:
|
||||
build-image:
|
||||
name: Build & Push Image
|
||||
runs-on: pwap-runner
|
||||
needs: [web, telegram-gate]
|
||||
needs: [web, notify-deploy-pending]
|
||||
if: |
|
||||
github.ref == 'refs/heads/main' &&
|
||||
(github.event_name == 'push' ||
|
||||
@@ -235,7 +235,7 @@ jobs:
|
||||
build-image-backend:
|
||||
name: Build & Push Backend Image
|
||||
runs-on: pwap-runner
|
||||
needs: [backend, telegram-gate]
|
||||
needs: [backend, notify-deploy-pending]
|
||||
if: |
|
||||
(github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/')) &&
|
||||
(github.event_name == 'push' ||
|
||||
@@ -298,31 +298,35 @@ jobs:
|
||||
echo "✅ Backend image signed (transparency log: rekor.sigstore.dev)"
|
||||
|
||||
# ========================================
|
||||
# TELEGRAM CEO APPROVAL GATE
|
||||
# Runs on self-hosted pwap-runner (DEV VPS) where pexsec-bot.service
|
||||
# writes the gate file to /tmp/pexsec-gates/<sha> when CEO clicks
|
||||
# Approve/Cancel in Telegram. 30-minute timeout = deploy cancelled.
|
||||
# DEPLOY APPROVAL
|
||||
# The approval is a GitHub Environment protection rule on `production`, not
|
||||
# something implemented here. No runner is held while it waits, the window is
|
||||
# 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:
|
||||
name: Telegram deploy approval
|
||||
notify-deploy-pending:
|
||||
name: Notify approver
|
||||
runs-on: pwap-runner
|
||||
needs: [web, security-audit]
|
||||
if: github.ref == 'refs/heads/main' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch')
|
||||
timeout-minutes: 5
|
||||
|
||||
steps:
|
||||
- name: Send approval request and wait
|
||||
- name: Send Telegram notification
|
||||
env:
|
||||
BOT_TOKEN: ${{ secrets.PEXSEC_BOT_TOKEN }}
|
||||
CEO_CHAT_ID: ${{ secrets.TELEGRAM_CEO_CHAT_ID }}
|
||||
SHA: ${{ github.sha }}
|
||||
ACTOR: ${{ github.actor }}
|
||||
MESSAGE: ${{ github.event.head_commit.message }}
|
||||
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
run: |
|
||||
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
|
||||
SAFE_MSG=$(echo "${MESSAGE}" | head -1 | tr -d '_*`[]()#|{}!' | cut -c1-120)
|
||||
|
||||
@@ -331,35 +335,15 @@ jobs:
|
||||
-d "{
|
||||
\"chat_id\": \"${CEO_CHAT_ID}\",
|
||||
\"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\": {
|
||||
\"inline_keyboard\": [[
|
||||
{\"text\": \"✅ Approve\", \"callback_data\": \"deploy_approve:${SHORT}\"},
|
||||
{\"text\": \"❌ Cancel\", \"callback_data\": \"deploy_cancel:${SHORT}\"}
|
||||
{\"text\": \"🔎 Review \& Approve\", \"url\": \"${RUN_URL}\"}
|
||||
]]
|
||||
}
|
||||
}"
|
||||
}" > /dev/null
|
||||
|
||||
echo "Waiting for Telegram approval (max 30 min)..."
|
||||
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
|
||||
echo "Approver notified; deployment waits on the 'production' environment."
|
||||
|
||||
# ========================================
|
||||
# VERSION BUMP (RUNS BEFORE BOTH DEPLOYS)
|
||||
@@ -367,7 +351,7 @@ jobs:
|
||||
bump-version:
|
||||
name: Bump Version
|
||||
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)
|
||||
if: |
|
||||
github.ref == 'refs/heads/main' &&
|
||||
@@ -413,10 +397,11 @@ jobs:
|
||||
deploy-app:
|
||||
name: Deploy app.pezkuwichain.io
|
||||
runs-on: pwap-runner
|
||||
needs: [telegram-gate, bump-version, build-image]
|
||||
environment: production
|
||||
needs: [notify-deploy-pending, bump-version, build-image]
|
||||
if: |
|
||||
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 == 'workflow_dispatch' && github.event.inputs.rollback_to != ''))
|
||||
permissions:
|
||||
@@ -582,10 +567,11 @@ jobs:
|
||||
deploy-pex:
|
||||
name: Deploy pex.mom
|
||||
runs-on: pwap-runner
|
||||
needs: [telegram-gate, bump-version, build-image]
|
||||
environment: production
|
||||
needs: [notify-deploy-pending, bump-version, build-image]
|
||||
if: |
|
||||
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 == 'workflow_dispatch' && github.event.inputs.rollback_to != ''))
|
||||
permissions:
|
||||
@@ -744,7 +730,7 @@ jobs:
|
||||
# Runs a container from the SHA-tagged GHCR image on the backend VPS.
|
||||
# Same discipline as the web deploy: cosign verify → deploy → health check →
|
||||
# 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
|
||||
# `pwap-indexer-db` (mounted at /data). Deploy replaces the container/image
|
||||
@@ -777,12 +763,13 @@ jobs:
|
||||
deploy-backend:
|
||||
name: Deploy Backend (indexer)
|
||||
runs-on: pwap-runner
|
||||
needs: [telegram-gate, build-image-backend, backend-cfg]
|
||||
environment: production
|
||||
needs: [notify-deploy-pending, build-image-backend, backend-cfg]
|
||||
if: |
|
||||
always() &&
|
||||
needs.backend-cfg.outputs.configured == 'true' &&
|
||||
(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 == 'workflow_dispatch' && github.event.inputs.rollback_to != ''))
|
||||
permissions:
|
||||
@@ -941,10 +928,11 @@ jobs:
|
||||
deploy-supabase:
|
||||
name: Deploy Supabase (functions + migrations)
|
||||
runs-on: pwap-runner
|
||||
needs: [telegram-gate]
|
||||
environment: production
|
||||
needs: [notify-deploy-pending]
|
||||
if: |
|
||||
always() &&
|
||||
needs.telegram-gate.result == 'success' &&
|
||||
needs.notify-deploy-pending.result == 'success' &&
|
||||
github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
@@ -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
|
||||
```
|
||||
Executable
+135
@@ -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"
|
||||
Reference in New Issue
Block a user