mirror of
https://github.com/pezkuwichain/pwap.git
synced 2026-08-12 09:11:01 +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:
|
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
|
||||||
|
|||||||
@@ -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