From bc207e661d493f3d0ecdbfa3b34904d2592f2c11 Mon Sep 17 00:00:00 2001 From: kj-podonos Date: Tue, 14 Jul 2026 17:32:56 +0900 Subject: [PATCH 1/4] 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/4] 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 != '' From 6c28cc9929c8c5c7aae24b59ff820910b6d899f5 Mon Sep 17 00:00:00 2001 From: kj-podonos Date: Wed, 15 Jul 2026 15:44:32 +0900 Subject: [PATCH 3/4] fix(cli): support run-steps SDK method migration --- src/onepin/_cli/_dispatch.py | 24 ++++++++++----- src/onepin/_cli/_spec.py | 10 ++++++- tests/build/test_sdk_contract.py | 14 ++++----- tests/unit/test_dispatch.py | 50 ++++++++++++++++++++++++++++++++ 4 files changed, 82 insertions(+), 16 deletions(-) diff --git a/src/onepin/_cli/_dispatch.py b/src/onepin/_cli/_dispatch.py index f7332d0..946e0be 100644 --- a/src/onepin/_cli/_dispatch.py +++ b/src/onepin/_cli/_dispatch.py @@ -55,12 +55,22 @@ def _annotation_for(opt: Opt) -> Any: return Optional[str] -def _resolve_method(client: Any, dotted: str) -> Callable[..., Any]: - """Resolve a dotted method path (e.g. ``workflows.runs.start``) against a built client.""" - obj: Any = client - for part in dotted.split("."): - obj = getattr(obj, part) - return obj # type: ignore[no-any-return] +def _resolve_method(client: Any, paths: str | tuple[str, ...]) -> Callable[..., Any]: + """Resolve the first available dotted SDK method path against a built client.""" + candidates = (paths,) if isinstance(paths, str) else paths + last_error: AttributeError | None = None + for dotted in candidates: + obj: Any = client + try: + for part in dotted.split("."): + obj = getattr(obj, part) + except AttributeError as exc: + last_error = exc + continue + return obj # type: ignore[no-any-return] + + attempted = ", ".join(repr(path) for path in candidates) + raise AttributeError(f"Could not resolve SDK method; attempted paths: {attempted}") from last_error def _coerce_value(value: Any) -> Any: @@ -411,7 +421,7 @@ def _run(cmd: Cmd, bound: dict[str, Any]) -> None: _confirm_destructive(cmd, bool(bound.get("yes", False)), json_on=json_on) client = get_client() - method = _resolve_method(client, cmd.method) + method = _resolve_method(client, cmd.method_paths) positional, kwargs = _build_kwargs(cmd, bound) if _accepts_workspace_kwarg(method): workspace = _state.root_options.get("workspace") diff --git a/src/onepin/_cli/_spec.py b/src/onepin/_cli/_spec.py index 9b88476..48a305f 100644 --- a/src/onepin/_cli/_spec.py +++ b/src/onepin/_cli/_spec.py @@ -60,6 +60,7 @@ class Cmd: name: Command name within the group/subgroup. method: Dotted SDK method path relative to the client, e.g. ``workflows.list`` or ``workflows.runs.start``. + fallback_methods: Transitional SDK method paths tried in order when ``method`` is absent. help: Command help text. subgroup: Optional nested sub-Typer (``runs``, ``members``, ``stats``). args: Positional arguments, ``[(dest, help)]``; forwarded positionally in order. @@ -85,6 +86,12 @@ class Cmd: success_msg: Optional[str] = None destructive: bool = False redact: bool = False + fallback_methods: tuple[str, ...] = () + + @property + def method_paths(self) -> tuple[str, ...]: + """SDK method paths in resolution order, canonical first.""" + return (self.method, *self.fallback_methods) @property def path(self) -> tuple[str, ...]: @@ -283,13 +290,14 @@ def _list_opts(*extra: Opt) -> list[Opt]: Cmd( "workflows", "steps", - "workflows.get_run_steps", + "workflows.runs.steps", "List the steps of a run.", subgroup="runs", args=[("workflow_id", "Workflow UUID."), ("run_id", "Run UUID.")], options=[_JSON], unwrap="list", columns=[], + fallback_methods=("workflows.get_run_steps",), ), Cmd( "workflows", diff --git a/tests/build/test_sdk_contract.py b/tests/build/test_sdk_contract.py index 84241e9..ff56d14 100644 --- a/tests/build/test_sdk_contract.py +++ b/tests/build/test_sdk_contract.py @@ -17,6 +17,7 @@ import pytest +from onepin._cli._dispatch import _resolve_method from onepin._cli._spec import TABLE, Cmd from onepin.client import OnePinClient @@ -25,23 +26,20 @@ _LOCAL_FLAGS = {"--json", "--reveal", "--yes"} -def _resolve_method(dotted: str): +def _resolve_cmd_method(cmd: Cmd): client = OnePinClient(token="op_live_test") - obj = client - for part in dotted.split("."): - obj = getattr(obj, part) - return obj + return _resolve_method(client, cmd.method_paths) @pytest.mark.parametrize("cmd", TABLE, ids=lambda c: ".".join(c.path)) def test_method_resolves(cmd: Cmd) -> None: - method = _resolve_method(cmd.method) + method = _resolve_cmd_method(cmd) assert callable(method), f"{cmd.method} is not callable" @pytest.mark.parametrize("cmd", TABLE, ids=lambda c: ".".join(c.path)) def test_declared_params_subset_of_signature(cmd: Cmd) -> None: - method = _resolve_method(cmd.method) + method = _resolve_cmd_method(cmd) sig = inspect.signature(method) params = sig.parameters accepts_kwargs = any(p.kind == inspect.Parameter.VAR_KEYWORD for p in params.values()) @@ -62,7 +60,7 @@ def test_declared_params_subset_of_signature(cmd: Cmd) -> None: @pytest.mark.parametrize("cmd", TABLE, ids=lambda c: ".".join(c.path)) def test_required_params_supplied(cmd: Cmd) -> None: - method = _resolve_method(cmd.method) + method = _resolve_cmd_method(cmd) sig = inspect.signature(method) supplied = {dest for dest, _ in cmd.args} diff --git a/tests/unit/test_dispatch.py b/tests/unit/test_dispatch.py index 6ddf0c3..8fa2bef 100644 --- a/tests/unit/test_dispatch.py +++ b/tests/unit/test_dispatch.py @@ -6,6 +6,7 @@ import enum import json from pathlib import Path +from types import SimpleNamespace import pytest @@ -14,6 +15,55 @@ from onepin._cli._spec import Cmd, Opt +class TestMethodResolution: + def test_resolves_canonical_path(self) -> None: + canonical = object() + client = SimpleNamespace(workflows=SimpleNamespace(runs=SimpleNamespace(steps=canonical))) + + assert _dispatch._resolve_method(client, ("workflows.runs.steps",)) is canonical + + def test_falls_back_when_canonical_path_is_absent(self) -> None: + legacy = object() + client = SimpleNamespace(workflows=SimpleNamespace(get_run_steps=legacy)) + + resolved = _dispatch._resolve_method( + client, + ("workflows.runs.steps", "workflows.get_run_steps"), + ) + + assert resolved is legacy + + def test_prefers_canonical_path_when_both_exist(self) -> None: + canonical = object() + legacy = object() + client = SimpleNamespace( + workflows=SimpleNamespace( + runs=SimpleNamespace(steps=canonical), + get_run_steps=legacy, + ) + ) + + resolved = _dispatch._resolve_method( + client, + ("workflows.runs.steps", "workflows.get_run_steps"), + ) + + assert resolved is canonical + + def test_reports_all_attempted_paths_when_none_resolve(self) -> None: + client = SimpleNamespace(workflows=SimpleNamespace()) + + with pytest.raises(AttributeError) as exc_info: + _dispatch._resolve_method( + client, + ("workflows.runs.steps", "workflows.get_run_steps"), + ) + + message = str(exc_info.value) + assert "workflows.runs.steps" in message + assert "workflows.get_run_steps" in message + + class TestTransforms: def test_wrap_list(self) -> None: opt = Opt("--sort", ("name",), None, transform="wrap_list") From b7f74c2d4c194cba770b2a8b267f97c43f9df8a5 Mon Sep 17 00:00:00 2001 From: kj-podonos Date: Wed, 15 Jul 2026 15:55:51 +0900 Subject: [PATCH 4/4] fix(cli): only fall back for missing SDK methods --- src/onepin/_cli/_dispatch.py | 8 ++++++-- tests/unit/test_dispatch.py | 20 ++++++++++++++++++++ 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/src/onepin/_cli/_dispatch.py b/src/onepin/_cli/_dispatch.py index 946e0be..637e522 100644 --- a/src/onepin/_cli/_dispatch.py +++ b/src/onepin/_cli/_dispatch.py @@ -65,8 +65,12 @@ def _resolve_method(client: Any, paths: str | tuple[str, ...]) -> Callable[..., for part in dotted.split("."): obj = getattr(obj, part) except AttributeError as exc: - last_error = exc - continue + try: + inspect.getattr_static(obj, part) + except AttributeError: + last_error = exc + continue + raise return obj # type: ignore[no-any-return] attempted = ", ".join(repr(path) for path in candidates) diff --git a/tests/unit/test_dispatch.py b/tests/unit/test_dispatch.py index 8fa2bef..5ce7792 100644 --- a/tests/unit/test_dispatch.py +++ b/tests/unit/test_dispatch.py @@ -50,6 +50,26 @@ def test_prefers_canonical_path_when_both_exist(self) -> None: assert resolved is canonical + def test_does_not_fall_back_when_canonical_attribute_raises(self) -> None: + class BrokenRuns: + @property + def steps(self) -> object: + raise AttributeError("canonical initialization failed") + + legacy = object() + client = SimpleNamespace( + workflows=SimpleNamespace( + runs=BrokenRuns(), + get_run_steps=legacy, + ) + ) + + with pytest.raises(AttributeError, match="canonical initialization failed"): + _dispatch._resolve_method( + client, + ("workflows.runs.steps", "workflows.get_run_steps"), + ) + def test_reports_all_attempted_paths_when_none_resolve(self) -> None: client = SimpleNamespace(workflows=SimpleNamespace())