From bc207e661d493f3d0ecdbfa3b34904d2592f2c11 Mon Sep 17 00:00:00 2001 From: kj-podonos Date: Tue, 14 Jul 2026 17:32:56 +0900 Subject: [PATCH 1/2] ci: notify Slack for PyPI approval --- .github/workflows/promote-prod.yml | 48 ++++++++++++++++++++++++++++++ RUNBOOK.md | 10 +++++-- docs/PUBLISH.md | 20 +++++++++---- 3 files changed, 70 insertions(+), 8 deletions(-) diff --git a/.github/workflows/promote-prod.yml b/.github/workflows/promote-prod.yml index 8e10e18..08b8085 100644 --- a/.github/workflows/promote-prod.yml +++ b/.github/workflows/promote-prod.yml @@ -307,6 +307,54 @@ jobs: - uses: actions/upload-artifact@v7 with: { name: dist, path: dist/* } + # Alert the release channel only after the exact artifact is built and validated. + # The protected `pypi` job starts waiting for GitHub approval in parallel; Slack is + # notification-only and a delivery failure must not change the publish decision. + notify-approval: + needs: [resolve, build] + runs-on: ubuntu-latest + permissions: {} + steps: + - name: Notify Slack + if: env.SLACK_WEBHOOK_URL != '' + continue-on-error: true + uses: slackapi/slack-github-action@v3 + with: + webhook: ${{ secrets.SLACK_WEBHOOK_URL }} + webhook-type: incoming-webhook + payload: | + text: " PyPI publish approval required for onepin==${{ needs.build.outputs.version }}" + blocks: + - type: section + text: + type: mrkdwn + text: " *PyPI publish approval required*" + - type: section + fields: + - type: mrkdwn + text: "*Package*\n`onepin==${{ needs.build.outputs.version }}`" + - type: mrkdwn + text: "*Release*\n`${{ needs.resolve.outputs.tag }}`" + - type: mrkdwn + text: "*Repository*\n`${{ github.repository }}`" + - type: mrkdwn + text: "*Trigger*\n${{ github.event_name == 'repository_dispatch' && 'Production deploy' || 'Manual replay' }}" + - type: actions + elements: + - type: button + text: + type: plain_text + text: Approve in GitHub + style: primary + url: "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" + env: + SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} + - name: Skip Slack notification + if: env.SLACK_WEBHOOK_URL == '' + run: echo "SLACK_WEBHOOK_URL is not configured; skipping approval notification." + env: + SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} + # PyPI publish — body reused verbatim from the original publish.yml `pypi` job # (OIDC trusted publishing, environment: pypi, build-provenance attestation). pypi: diff --git a/RUNBOOK.md b/RUNBOOK.md index 7594a0c..70a135d 100644 --- a/RUNBOOK.md +++ b/RUNBOOK.md @@ -69,7 +69,9 @@ The package publishes from **two independent lanes** (full model + diagram: `repository_dispatch[api-spec-updated]` **only when `environment == 'prod'`** (the backend production deploy dispatch), or manually via `workflow_dispatch`. Resolves the latest `vX.Y.Z` tag, runs the immutable-index idempotency check (in `resolve`), - builds a clean `X.Y.Z`, then publishes to **PyPI** (OIDC trusted publishing + provenance). + and builds a clean `X.Y.Z`. A best-effort `@channel` Slack card links to the exact public + Actions run; an authorized reviewer must select **Approve in GitHub** before the protected + `pypi` environment publishes to **PyPI** (OIDC trusted publishing + provenance). > **PyPI Trusted Publisher prereq:** the `onepin` PyPI project's Trusted Publisher must > point at workflow filename **`promote-prod.yml`** with environment **`pypi`** (it was @@ -80,7 +82,10 @@ The package publishes from **two independent lanes** (full model + diagram: Merging the release-please PR tags the version; **`publish.yml`** then builds → **TestPyPI**. The customer-facing **PyPI** publish happens separately in **`promote-prod.yml`**, gated on a -real backend **prod** deploy dispatch (or a manual promote). +real backend **prod** deploy dispatch (or a manual promote). Once the candidate passes its +build checks, `notify-approval` posts the package version, tag, repository, trigger type, and +public Actions run link to Slack. Open **Approve in GitHub** from that card and approve the +pending `pypi` environment; the Slack message itself cannot approve or publish anything. Auto-publish on the tag requires the **`RELEASE_PAT`** repo secret. release-please uses this token for **all** its GitHub calls, so it needs both `contents: write` (to push the tag, which @@ -108,6 +113,7 @@ Manual fallback / replay — **PyPI** (promote an existing release tag to custom # Used when the pipeline App was absent at the prod deploy, or to re-drive a promote. # Supplying -f tag= bypasses the per-sha ancestry resolver (human override). # resolve skips (no-op success) a version already on PyPI; only ambiguous PyPI responses abort. +# A new version still waits for the existing GitHub pypi-environment approval. gh workflow run promote-prod.yml --ref main -f tag=vX.Y.Z ``` diff --git a/docs/PUBLISH.md b/docs/PUBLISH.md index f15b8e1..2b6546f 100644 --- a/docs/PUBLISH.md +++ b/docs/PUBLISH.md @@ -11,7 +11,7 @@ must only ever receive a build that a real production deploy blessed.** | Cadence | **continuous** | **prod-gated** | | Trigger | push to `main` (SDK inputs) · release tag `vX.Y.Z` · `workflow_dispatch` | `repository_dispatch[api-spec-updated]` with `environment == 'prod'` · `workflow_dispatch` | | Version | `X.Y.Z.devN` on main · clean `X.Y.Z` on a tag (hatch-vcs) | clean `X.Y.Z` only (built from the release tag) | -| Gate | none (internal) | App-token guard **+** prod-environment trigger **+** PyPI idempotency check | +| Gate | none (internal) | App-token guard **+** prod-environment trigger **+** PyPI idempotency check **+** required GitHub environment approval | ## Version ownership @@ -63,7 +63,10 @@ must only ever receive a build that a real production deploy blessed.** │ idempotency check (resolve): 200 ⇒ skip no-op · 404 ⇒ publish · else abort │ │ build resolved tag (fetch-depth:0, hatch-vcs → clean X.Y.Z) │ │ assert ^[0-9]+\.[0-9]+\.[0-9]+$ (NO .devN / NO +local) │ + │ Slack: @channel approval card + exact GitHub Actions run link │ + │ pypi environment: WAIT for an authorized GitHub reviewer │ │ │ │ + │ Approve in GitHub │ │ ▼ │ │ ✦ PyPI ✦ (OIDC trusted publishing, environment: pypi, │ │ build-provenance attestation) │ @@ -144,21 +147,26 @@ a manual settings change in the PyPI project — the workflow cannot self-config it `::warning::` warn-skips (no PyPI mutation). Dispatch a simulated `repository_dispatch` with `environment: dev`: assert it `::notice::` skips. - **Idempotency (no-op)** — `workflow_dispatch` `promote-prod.yml` with `tag` = an **already-published** - version: assert `resolve` logs `200`, sets `already_published=true`, and **`build` + `pypi` skip** - (green run, no Slack) — not an abort. + version: assert `resolve` logs `200`, sets `already_published=true`, and + **`build` + `notify-approval` + `pypi` skip** (green run, no Slack) — not an abort. - **actionlint** — `actionlint .github/workflows/*.yml` clean (CI-enforceable). ### 3. End-to-end (real release, gated on the App existing) - Merge a `feat:` regen PR → release-please opens a release PR → merge it → tag `vX.Y.Z` → `publish.yml` fires → TestPyPI gets the clean `X.Y.Z`. - Real backend **prod** deploy (`deploy-prod.yml`) → `repository_dispatch{environment:prod}` - → `promote-prod.yml` resolves the tag (PyPI check: `404` ⇒ absent), builds clean `X.Y.Z`, **PyPI** - publish + provenance attestation. Verify `pip install onepin==X.Y.Z` from pypi.org and - `onepin --version`. + → `promote-prod.yml` resolves the tag (PyPI check: `404` ⇒ absent), builds clean `X.Y.Z`, + posts the Slack approval card, and waits on the protected `pypi` environment. Follow + **Approve in GitHub**, then verify the **PyPI** publish + provenance attestation, + `pip install onepin==X.Y.Z` from pypi.org, and `onepin --version`. - **Manual replay** path: `gh workflow run promote-prod.yml -f tag=vX.Y.Z` reaches PyPI identically (used when the App was absent at the original prod deploy). ### 4. Observability +- **Slack approval notifier** (PyPI lane, `notify-approval`, gated on `SLACK_WEBHOOK_URL`): + after a publish-ready build, posts `@channel` with only public release metadata and an + **Approve in GitHub** button linked to the exact Actions run. It is skipped on idempotent + no-ops and configured best-effort so Slack cannot change the GitHub approval gate. - **Slack failure notifier** on both lanes (`notify-failure`, gated on `SLACK_WEBHOOK_URL`): fires on any build/publish failure with a direct run link. A clean warn-skip does **not** fire (skipped ≠ failed). From 872e987503e7b7bcd7a7e880a5fdcd2a67e0f230 Mon Sep 17 00:00:00 2001 From: kj-podonos Date: Wed, 15 Jul 2026 10:36:08 +0900 Subject: [PATCH 2/2] ci: gate Slack notify jobs on job-level webhook env The `if: env.SLACK_WEBHOOK_URL != ''` guard read a step-level env var, which GitHub does not expose to that same step's `if` (only workflow/job env is visible). The gate always evaluated empty, so notify-approval, notify-success, and notify-failure always took the skip branch and never posted to Slack. Hoist SLACK_WEBHOOK_URL to job-level env on all three jobs so the gate resolves. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/promote-prod.yml | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/.github/workflows/promote-prod.yml b/.github/workflows/promote-prod.yml index 08b8085..2aa46d0 100644 --- a/.github/workflows/promote-prod.yml +++ b/.github/workflows/promote-prod.yml @@ -314,6 +314,10 @@ jobs: needs: [resolve, build] runs-on: ubuntu-latest permissions: {} + # Job-level env so the step `if` gate can see it — GitHub does NOT expose a + # step's own `env:` block to that same step's `if` conditional. + env: + SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} steps: - name: Notify Slack if: env.SLACK_WEBHOOK_URL != '' @@ -391,6 +395,10 @@ jobs: needs: [resolve, build, pypi] if: ${{ needs.pypi.result == 'success' }} # ONLY when pypi actually published — skipped on the no-op path runs-on: ubuntu-latest + # Job-level env so the step `if` gate below can see it (step-level env is not + # visible to the same step's `if`). + env: + SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} steps: - name: Notify Slack if: env.SLACK_WEBHOOK_URL != '' @@ -413,6 +421,10 @@ jobs: if: failure() needs: [resolve, build, pypi] runs-on: ubuntu-latest + # Job-level env so the step `if` gate below can see it (step-level env is not + # visible to the same step's `if`). + env: + SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} steps: - name: Notify Slack if: env.SLACK_WEBHOOK_URL != ''