mandrel-platform 0.21.0 → 0.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,150 @@
1
+ # Runner Fleet Health Monitor
2
+
3
+ > **Self-contained runbook** (not a thin stub). Unlike most templates in this
4
+ > directory, there is no canonical `docs/runbooks/` counterpart to link — this
5
+ > file IS the process, mirroring `runner-provisioning.md`. It documents the
6
+ > scheduled `.github/workflows/runner-fleet-health.yml` monitor (Story #258):
7
+ > what it checks, the token scope it needs, the alert semantics, and the
8
+ > operator response when it fires.
9
+
10
+ ---
11
+
12
+ ## Why this exists
13
+
14
+ All self-hosted runners across the fleet (`domio`, `athportal`, `swarm-os`)
15
+ are **co-resident on one operator Mac**. If that host sleeps, reboots for an
16
+ OS update, fills its disk, or a launchd runner service dies, **every
17
+ consumer's CI and deploy-trigger jobs silently queue** ("waiting for a
18
+ runner") with no alert. Nothing else watches this:
19
+
20
+ - The Better Stack uptime unit (`uptime-apply.yml`) monitors the **deployed
21
+ apps**, not the runners.
22
+ - Deploy pipelines are `workflow_run`-gated, so a wedged runner can silently
23
+ stall staging indefinitely — no failed job, no notification, just a queue
24
+ that never drains.
25
+
26
+ > **Roster note:** `Beestera/swarm-os` is monitored only *indirectly*. No
27
+ > `dsj1984`-owned token can read another org's runner API (fine-grained PATs
28
+ > are bound to one resource owner; the Beestera org rejects classic PATs), so
29
+ > its roster entry would permanently false-positive as `0/3` degraded.
30
+ > Because its runners share the Mac with the rostered repos, a wedged
31
+ > **host** still trips the `domio`/`athportal` rows; what goes unwatched is a
32
+ > swarm-os-only launchd service death and its stale queue. See
33
+ > `$comment_swarm_os` in `scripts/runner-fleet-consumers.json`.
34
+
35
+ `runner-fleet-health.yml` is the standing check that catches this fast.
36
+
37
+ ## What it checks
38
+
39
+ Runs on a schedule (~every 15 minutes) plus `workflow_dispatch`, on
40
+ `ubuntu-latest` (deliberately GitHub-hosted so it keeps running when the Mac
41
+ is down). For each repo in `scripts/runner-fleet-consumers.json` it calls
42
+ `GET /repos/{owner}/{repo}/actions/runners` and:
43
+
44
+ 1. **Offline runners** — flags any runner whose `status != online`.
45
+ 2. **Count shortfall** — flags fewer online runners matching the repo's
46
+ expected `labels` set than its configured `expectedCount`.
47
+ 3. **Stale queued runs** (optional signal) — a `queued`/`waiting` workflow run
48
+ older than `staleQueuedMinutes` (default 20) with no online runner matching
49
+ its labels. This catches the case where the runner *looks* present in the
50
+ roster count but is actually wedged and not claiming jobs.
51
+
52
+ It renders a per-repo dashboard on `GITHUB_STEP_SUMMARY`.
53
+
54
+ ## Config-driven roster
55
+
56
+ Adding, removing, or resizing a runner needs **only a config edit** —
57
+ `scripts/runner-fleet-consumers.json`:
58
+
59
+ ```jsonc
60
+ {
61
+ "defaultStaleQueuedMinutes": 20,
62
+ "repos": [
63
+ { "name": "domio", "repo": "dsj1984/domio", "expectedCount": 3, "labels": ["self-hosted", "macOS", "ARM64", "domio-runner"] },
64
+ // ... one object per repo
65
+ ],
66
+ }
67
+ ```
68
+
69
+ ## Token scope: `PIN_DRIFT_TOKEN`
70
+
71
+ The monitor reuses the same fine-grained PAT `pin-drift.yml` already
72
+ provisions (`secrets.PIN_DRIFT_TOKEN`), falling back to the built-in
73
+ `github.token` when the secret is absent (the built-in token only grants read
74
+ access to the workflow's own repo — cross-repo rows then surface as `⚠️
75
+ error` rather than hard-failing this repo's own row).
76
+
77
+ For the runner reads, `PIN_DRIFT_TOKEN` must carry, on every rostered repo:
78
+
79
+ - **Administration: read** — required by `GET .../actions/runners` (the
80
+ self-hosted runner list is an admin-surface endpoint; `actions:read` is
81
+ NOT sufficient for it).
82
+ - **Actions: read** — required by `GET .../actions/runs` (the stale-queue
83
+ check).
84
+
85
+ Resource-owner caveat: a fine-grained PAT is bound to a **single** resource
86
+ owner, and the Beestera org rejects classic PATs — which is exactly why
87
+ `Beestera/swarm-os` is off the roster (see the roster note above). Every
88
+ rostered repo must be readable by the ONE token this workflow gets; a repo
89
+ the token cannot see 404s and false-positives as degraded, so extend the
90
+ roster only together with a credential that covers the new repo.
91
+
92
+ When the token lacks visibility, GitHub returns **404** (not 403) and the
93
+ script treats the empty runner list as a real shortfall — the repo's row
94
+ reads `❌ degraded` with `0/N` online even when the runners are healthy. A
95
+ fleet-wide `0/N` across every repo is the token-misconfiguration signature;
96
+ check the secret before touching the runner host.
97
+
98
+ ## Alert semantics
99
+
100
+ Alert-only by design (no host-side remediation) — the monitor never touches
101
+ the runner host itself. One channel fires on an unhealthy repo, deliberately
102
+ without adding a new external dependency:
103
+
104
+ - **Native GitHub failed-workflow notification.** The job script exits
105
+ non-zero when any repo is unhealthy, so GitHub's own email/notification
106
+ settings fire the standard "workflow run failed" alert to whoever
107
+ watches this repo. No tracking issues are filed — the dashboard detail
108
+ lives on the failed run's job summary.
109
+
110
+ A future Slack/PagerDuty push could layer on top of this later — deliberately
111
+ deferred (see the Story's Out of Scope) to avoid a new external dependency for
112
+ the initial alert-only default.
113
+
114
+ ## Operator response
115
+
116
+ When the scheduled workflow run fails:
117
+
118
+ 1. **Read the dashboard** on the workflow run's job summary — it names which
119
+ signal fired (offline runner, count shortfall, or stale queued run) and
120
+ for which repo.
121
+ 2. **Wake or reboot the Mac** if it's asleep, powered off, or unresponsive
122
+ over SSH.
123
+ 3. **Check disk space** (`df -h`) — a full disk is a common launchd-runner
124
+ death cause; free space and restart the affected runner service(s).
125
+ 4. **Restart the launchd runner service(s)** for the affected repo:
126
+ ```bash
127
+ cd <RUNNER_DIR> # see templates/runbooks/runner-provisioning.md
128
+ ./svc.sh stop && ./svc.sh start
129
+ ./svc.sh status # expect: Started · running
130
+ ```
131
+ 5. **Re-run the monitor** (`workflow_dispatch` from the Actions tab, or wait
132
+ for the next 15-minute tick) to confirm recovery — a green run means the
133
+ fleet reports healthy again.
134
+
135
+ ## Out of scope (Story #258)
136
+
137
+ - Host-side remediation / auto-recovery (waking the Mac, restarting services)
138
+ — this monitor is alert-only; the operator performs the response above by
139
+ hand.
140
+ - Host disk-usage monitoring — not exposable via the runners API anyway, and
141
+ tracked separately.
142
+ - External paging integrations beyond the native failed-workflow
143
+ notification.
144
+ - Cross-repo runner isolation / ephemeral-runner questions — explicitly
145
+ deferred.
146
+
147
+ ## Project-Specific Notes
148
+
149
+ <!-- Record host quirks, roster changes, or false-positive tuning
150
+ (staleQueuedMinutes overrides) specific to this fleet. -->
@@ -0,0 +1,117 @@
1
+ name: deploy-staging-run
2
+
3
+ # Canonical staging-deploy caller template — DEPLOY half (Story #272).
4
+ #
5
+ # > **Why this file runs on `workflow_dispatch`.** Its sibling
6
+ # > `deploy-staging.yml` fires on CI-green (`workflow_run`) and DISPATCHES this
7
+ # > workflow. The deploy lives here, on `workflow_dispatch`, because the shared
8
+ # > `deploy-cloudflare.yml`'s `environment:`-gated jobs (`check-env` /
9
+ # > `migration` / `deploy` / `boot-smoke`) are silently SKIPPED on a
10
+ # > `workflow_run` event but run normally on `workflow_dispatch` (Story #272).
11
+ # > Adopt this file together with `deploy-staging.yml`; `platform-sync`
12
+ # > materializes both.
13
+ #
14
+ # > **Thin local caller.** The defence-in-depth deploy core lives in the shared
15
+ # > `dsj1984/mandrel-platform` `deploy-cloudflare.yml` reusable workflow — see
16
+ # > https://github.com/dsj1984/mandrel-platform/blob/main/docs/reusable-workflows.md#deploy-cloudflareyml.
17
+ # > This file only holds <PROJECT_NAME>-specific values (worker names, build
18
+ # > step, secret mapping). When the deploy PROCESS changes, that change lands
19
+ # > upstream in mandrel-platform — not here.
20
+ #
21
+ # Replace every <PLACEHOLDER> with your project's real values:
22
+ # <MANDREL_PLATFORM_SHA> the pinned mandrel-platform commit SHA (resolve via
23
+ # `node scripts/platform-sync.mjs --ref <release-tag>`
24
+ # from the consumer repo root, or `git ls-remote`).
25
+ # <MANDREL_PLATFORM_TAG> the human-readable release tag matching the SHA
26
+ # above (trailing `# <tag>` comment).
27
+ # <WORKERS_CSV> comma-separated Worker names for this env, e.g.
28
+ # "api,web".
29
+ # <BUILD_COMMAND> optional build command (omit build-command /
30
+ # build-artifact entirely if the deploy job's default
31
+ # checkout is build-ready).
32
+ #
33
+ # See the full input/secret contract:
34
+ # https://github.com/dsj1984/mandrel-platform/blob/main/docs/reusable-workflows.md#deploy-cloudflareyml
35
+
36
+ on:
37
+ # Dispatched by deploy-staging.yml on CI-green, and available for manual
38
+ # on-demand deploys (UI "Run workflow" + `gh workflow run`). The optional sha
39
+ # input records the CI-verified commit; the deploy itself runs against the
40
+ # main tip (`--ref main`).
41
+ workflow_dispatch:
42
+ inputs:
43
+ sha:
44
+ description: >
45
+ Commit SHA that passed CI (informational — the deploy runs against
46
+ the current main tip). Populated automatically when dispatched by
47
+ deploy-staging.yml.
48
+ required: false
49
+ type: string
50
+
51
+ permissions:
52
+ contents: read
53
+
54
+ # Serialize staging deploys: QUEUE, don't cancel (Story #284 / audit H2). An
55
+ # in-flight `wrangler d1 migrations apply` must never be cancelled mid-apply by
56
+ # the next dispatch — a half-applied forward-only migration has no automatic
57
+ # restore. `cancel-in-progress: false` lets a second dispatch wait for the
58
+ # first to finish. (The dispatcher half keeps `cancel-in-progress: true`:
59
+ # cancelling a superseded PENDING dispatch is safe.) The shared
60
+ # deploy-cloudflare.yml additionally serializes per-environment.
61
+ concurrency:
62
+ group: deploy-staging-run
63
+ cancel-in-progress: false
64
+
65
+ jobs:
66
+ # M4: sha-drift preflight (Story #284). The CI-green gate is TOCTOU-racy — a
67
+ # commit A goes green and dispatches, but commit B (CI pending, possibly
68
+ # later red) may already be on main, so the deploy would ship B under A's
69
+ # green credential. When `sha` is supplied (dispatcher path), assert the main
70
+ # tip still equals it and FAIL loudly on drift, naming both SHAs. A job with
71
+ # `uses:` cannot carry `steps:`, so this preflight is a SEPARATE preceding
72
+ # job that the `uses:` deploy job `needs:`. Manual dispatches without `sha`
73
+ # skip the assertion (the operator is deploying the current tip on purpose).
74
+ sha-drift-preflight:
75
+ name: Sha-drift preflight (deploy what CI verified)
76
+ runs-on: ubuntu-latest
77
+ timeout-minutes: 5
78
+ steps:
79
+ - name: Checkout main tip
80
+ uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
81
+ with:
82
+ ref: main
83
+ persist-credentials: false
84
+ - name: Assert HEAD matches the CI-verified sha
85
+ env:
86
+ INPUT_SHA: ${{ inputs.sha }}
87
+ shell: bash
88
+ run: |
89
+ set -euo pipefail
90
+ if [ -z "${INPUT_SHA}" ]; then
91
+ echo "No sha input — manual dispatch deploying the current main tip; skipping sha-drift preflight."
92
+ exit 0
93
+ fi
94
+ head="$(git rev-parse HEAD)"
95
+ if [ "${head}" != "${INPUT_SHA}" ]; then
96
+ echo "::error::sha drift: main tip is ${head} but the dispatched (CI-verified) sha is ${INPUT_SHA}. main advanced after CI went green; refusing to deploy an unverified tip." >&2
97
+ exit 1
98
+ fi
99
+ echo "sha-drift preflight OK: main tip ${head} matches the CI-verified sha ${INPUT_SHA}."
100
+
101
+ deploy:
102
+ name: Staging deploy (shared deploy-cloudflare.yml)
103
+ needs: [sha-drift-preflight]
104
+ uses: dsj1984/mandrel-platform/.github/workflows/deploy-cloudflare.yml@<MANDREL_PLATFORM_SHA> # <MANDREL_PLATFORM_TAG>
105
+ with:
106
+ environment: staging
107
+ gh-environment: staging
108
+ workers: <WORKERS_CSV>
109
+ migrate: true
110
+ # db-engine defaults to 'd1'. Set db-engine + migrate-command +
111
+ # snapshot-command for a non-D1 engine (e.g. Turso) — see the contract
112
+ # doc's "command seams" section.
113
+ # Frozen secret allowlist: only {CLOUDFLARE_*, TURSO_*} cross into the
114
+ # shared workflow. Map your project's secret NAMES onto these slots.
115
+ secrets:
116
+ CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
117
+ CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
@@ -1,89 +1,150 @@
1
1
  name: deploy-staging
2
2
 
3
- # Canonical staging-deploy caller template (Story #175).
3
+ # Canonical staging-deploy caller template — DISPATCHER half (Story #175,
4
+ # reworked for Story #272).
5
+ #
6
+ # > **Why two files (Story #272).** A reusable workflow's `environment:`-gated
7
+ # > jobs (`deploy-cloudflare.yml`'s `check-env` / `migration` / `deploy` /
8
+ # > `boot-smoke`) are **silently skipped on a `workflow_run` event** — a
9
+ # > documented GitHub limitation. The previous single-file template called
10
+ # > `deploy-cloudflare.yml` DIRECTLY from `on: workflow_run`, so those jobs
11
+ # > skipped and **0 workers deployed while the run reported green** (all-skipped,
12
+ # > none failed). One consumer hid 40+ consecutive non-deploys this way.
13
+ # >
14
+ # > The fix: this file is now a thin **dispatcher** that fires on CI-green and
15
+ # > re-launches the deploy via **`workflow_dispatch`** — the event on which the
16
+ # > `environment:` jobs DO run — against its sibling `deploy-staging-run.yml`.
17
+ # > `workflow_dispatch` and `repository_dispatch` are the two events that
18
+ # > "always create workflow runs" even when triggered with the built-in
19
+ # > `GITHUB_TOKEN`, so **no PAT is required** for this same-repo dispatch — just
20
+ # > `permissions: actions: write` below. (Cross-repo dispatch, like
21
+ # > `smoke-dispatch.yml`, still needs a PAT; same repo does not.)
4
22
  #
5
23
  # > **Thin local caller.** The defence-in-depth deploy core (secret-isolation
6
24
  # > audit -> CF env gate -> migration (snapshot + apply) -> deploy ->
7
- # > boot-smoke + auto-rollback) AND the CI-green guard both live in the shared
25
+ # > boot-smoke + auto-rollback) lives in the shared
8
26
  # > `dsj1984/mandrel-platform` `deploy-cloudflare.yml` reusable workflow — see
9
27
  # > https://github.com/dsj1984/mandrel-platform/blob/main/docs/reusable-workflows.md#deploy-cloudflareyml.
10
- # > This file only holds <PROJECT_NAME>-specific values (worker names, build
11
- # > step, secret mapping). When the deploy PROCESS changes, that change lands
12
- # > upstream in mandrel-platform — not here.
13
- #
14
- # One paved road (operator decision 2026-07-01, D4): every consumer triggers
15
- # staging deploy via `workflow_run` on its own CI workflow, gated on
16
- # `conclusion == 'success'`. `workflow_run` fires on BOTH a successful AND a
17
- # failed upstream run, so a caller-side guard against a red run used to be
18
- # REQUIRED here — every consumer hand-copied the same `preflight` job (see
19
- # mandrel-platform Story #175 context). That guard now lives INSIDE
20
- # `deploy-cloudflare.yml` itself as a job-level `if:` on its entry jobs
21
- # (`github.event` inside a reusable workflow is the CALLER's event, so the
22
- # shared workflow can see and gate on the `workflow_run` conclusion even
23
- # though it cannot own this file's `on:` block); a red upstream run skips
24
- # the whole chain with zero runner spin-ups (mandrel-platform Story #237).
25
- # This template needs NO caller-side preflight guard as a result —
26
- # copy it as-is and fill in the placeholders below.
28
+ # > The <PROJECT_NAME>-specific values (worker names, build step, secret
29
+ # > mapping) live in the sibling `deploy-staging-run.yml`. When the deploy
30
+ # > PROCESS changes, that change lands upstream in mandrel-platform — not here.
27
31
  #
28
- # Replace every <PLACEHOLDER> with your project's real values:
29
- # <CI_WORKFLOW_NAME> the `name:` of the workflow this deploy should
30
- # gate on (e.g. "quality", "CI", "PR Quality").
31
- # Must match EXACTLY GitHub matches
32
- # `workflow_run.workflows` by workflow name, not
33
- # file path.
34
- # <MANDREL_PLATFORM_SHA> the pinned mandrel-platform commit SHA (resolve
35
- # via `node scripts/platform-sync.mjs --ref
36
- # <release-tag>` from the consumer repo root, or
37
- # hand-resolve via `git ls-remote`).
38
- # <MANDREL_PLATFORM_TAG> the human-readable release tag matching the SHA
39
- # above (trailing `# <tag>` comment).
40
- # <WORKERS_CSV> comma-separated Worker names for this env, e.g.
41
- # "api,web".
42
- # <BUILD_COMMAND> optional build command (omit build-command /
43
- # build-artifact entirely if the deploy job's
44
- # default checkout is build-ready).
32
+ # Adopt BOTH files together: this `deploy-staging.yml` (dispatcher) and
33
+ # `deploy-staging-run.yml` (the actual deploy). `platform-sync` materializes
34
+ # both. Replace every <PLACEHOLDER>:
35
+ # <CI_WORKFLOW_NAME> the `name:` of the workflow this deploy gates on (e.g.
36
+ # "quality", "CI"). Must match EXACTLY — GitHub matches
37
+ # `workflow_run.workflows` by workflow name, not path.
45
38
  #
46
39
  # See the full input/secret contract:
47
40
  # https://github.com/dsj1984/mandrel-platform/blob/main/docs/reusable-workflows.md#deploy-cloudflareyml
48
41
 
49
42
  on:
50
- # CI-green gate: fires when <CI_WORKFLOW_NAME> finishes on main. The shared
51
- # deploy-cloudflare.yml's job-level CI-green gate skips the entire job
52
- # chain (zero runners) unless the upstream conclusion was 'success' no
53
- # caller-side guard needed.
43
+ # CI-green gate: fires when <CI_WORKFLOW_NAME> completes on main. Unlike the
44
+ # old shape, this does NOT call the deploy directly — a `workflow_run` deploy
45
+ # would skip every `environment:` job. It only DISPATCHES the deploy (below)
46
+ # on success, so the actual deploy runs on `workflow_dispatch` where those
47
+ # jobs execute. A red upstream run simply does not dispatch — there is no
48
+ # green-but-didn't-deploy run at all.
54
49
  workflow_run:
55
50
  workflows: [<CI_WORKFLOW_NAME>]
56
51
  branches: [main]
57
52
  types: [completed]
58
- # Manual on-demand trigger (UI "Run workflow" + `gh workflow run`).
59
- # workflow_dispatch always passes the shared workflow's CI-green guard
60
- # (operator-intentional, no upstream conclusion to gate on).
61
- workflow_dispatch:
62
53
 
54
+ # actions:write lets the built-in GITHUB_TOKEN dispatch deploy-staging-run.yml
55
+ # via the workflow_dispatch API. No PAT needed for a same-repo dispatch —
56
+ # workflow_dispatch always creates a run even from GITHUB_TOKEN.
63
57
  permissions:
64
58
  contents: read
59
+ actions: write
65
60
 
66
- # Cancel an in-flight staging deploy when a newer commit lands on main — only
67
- # the freshest tip of main should reach the staging surfaces. The shared
68
- # deploy-cloudflare.yml additionally serializes per-environment.
61
+ # Only the freshest tip of main should reach staging: cancel an in-flight
62
+ # DISPATCH when a newer CI run completes. (The deploy itself is serialized
63
+ # separately in deploy-staging-run.yml and per-environment inside the shared
64
+ # deploy-cloudflare.yml.)
69
65
  concurrency:
70
- group: deploy-staging
66
+ group: deploy-staging-dispatch
71
67
  cancel-in-progress: true
72
68
 
73
69
  jobs:
74
- deploy:
75
- name: Staging deploy (shared deploy-cloudflare.yml)
76
- uses: dsj1984/mandrel-platform/.github/workflows/deploy-cloudflare.yml@<MANDREL_PLATFORM_SHA> # <MANDREL_PLATFORM_TAG>
77
- with:
78
- environment: staging
79
- gh-environment: staging
80
- workers: <WORKERS_CSV>
81
- migrate: true
82
- # db-engine defaults to 'd1'. Set db-engine + migrate-command +
83
- # snapshot-command for a non-D1 engine (e.g. Turso) see the contract
84
- # doc's "command seams" section.
85
- # Frozen secret allowlist: only {CLOUDFLARE_*, TURSO_*} cross into the
86
- # shared workflow. Map your project's secret NAMES onto these slots.
87
- secrets:
88
- CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
89
- CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
70
+ dispatch:
71
+ name: Dispatch staging deploy on CI-green
72
+ runs-on: ubuntu-latest
73
+ timeout-minutes: 5
74
+ # CI-green gate — three load-bearing conditions, all required (Story #284):
75
+ # 1. `conclusion == 'success'` — `workflow_run` fires on both success and
76
+ # failure; without this a red main would still deploy.
77
+ # 2. `event == 'push'` — the upstream CI run must itself have been a push
78
+ # to the repo, not a `pull_request` run. `workflow_run.branches:
79
+ # [main]` filters on the *head branch NAME*, so a fork PR whose head
80
+ # branch is literally named `main` with green CI otherwise satisfies
81
+ # the branch filter and the success guard (audit M3).
82
+ # 3. `head_repository.full_name == github.repository` the CI run must
83
+ # have originated from THIS repo, not a fork. Belt-and-suspenders with
84
+ # condition 2: even a same-name fork branch cannot spoof same-repo
85
+ # provenance, so an external contributor can no longer attacker-time a
86
+ # staging deploy off their fork's green CI.
87
+ if: >-
88
+ ${{ github.event.workflow_run.conclusion == 'success'
89
+ && github.event.workflow_run.event == 'push'
90
+ && github.event.workflow_run.head_repository.full_name == github.repository }}
91
+ steps:
92
+ - name: Dispatch deploy-staging-run.yml (workflow_dispatch)
93
+ env:
94
+ GH_TOKEN: ${{ github.token }}
95
+ REPO: ${{ github.repository }}
96
+ SHA: ${{ github.event.workflow_run.head_sha }}
97
+ shell: bash
98
+ run: |
99
+ set -euo pipefail
100
+
101
+ # L3: the dispatch is otherwise fire-and-forget — a transient GitHub
102
+ # API 5xx fails `gh workflow run`, and the only signal is a red run in
103
+ # a low-visibility dispatcher while that green commit never reaches
104
+ # staging. Retry with backoff, then VERIFY a run was actually created
105
+ # (a 2xx from the dispatch API is not proof a run materialized).
106
+ dispatched=false
107
+ for attempt in 1 2 3; do
108
+ if gh workflow run deploy-staging-run.yml \
109
+ --repo "${REPO}" \
110
+ --ref main \
111
+ -f sha="${SHA}"; then
112
+ dispatched=true
113
+ break
114
+ fi
115
+ echo "::warning::gh workflow run attempt ${attempt} failed; retrying after backoff."
116
+ sleep $((attempt * 5))
117
+ done
118
+
119
+ if [ "${dispatched}" != "true" ]; then
120
+ echo "::error::Failed to dispatch deploy-staging-run.yml after 3 attempts for ${SHA}." >&2
121
+ exit 1
122
+ fi
123
+
124
+ # Verify a runner workflow run was actually created. The dispatch API
125
+ # returns before the run row is queryable, so poll `gh run list` for a
126
+ # recent workflow_dispatch run of the runner workflow. Fail loudly if
127
+ # none appears — a silent non-creation is exactly the failure mode
128
+ # this check exists to surface.
129
+ created=false
130
+ for attempt in 1 2 3 4 5; do
131
+ count="$(gh run list \
132
+ --repo "${REPO}" \
133
+ --workflow deploy-staging-run.yml \
134
+ --event workflow_dispatch \
135
+ --limit 5 \
136
+ --json databaseId --jq 'length' 2>/dev/null || echo 0)"
137
+ if [ "${count:-0}" -gt 0 ]; then
138
+ created=true
139
+ break
140
+ fi
141
+ echo "Run not visible yet (attempt ${attempt}); waiting for the run row to appear."
142
+ sleep $((attempt * 3))
143
+ done
144
+
145
+ if [ "${created}" != "true" ]; then
146
+ echo "::error::Dispatched deploy-staging-run.yml for ${SHA} but no runner workflow run appeared via 'gh run list'." >&2
147
+ exit 1
148
+ fi
149
+
150
+ echo "Dispatched and verified staging deploy for ${SHA} (deploy runs on workflow_dispatch so environment: jobs execute)."