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.
- package/package.json +2 -2
- package/scripts/check-pin-drift.mjs +66 -1
- package/scripts/check-pin-drift.test.mjs +125 -0
- package/scripts/check-runner-health.mjs +469 -0
- package/scripts/check-runner-health.test.mjs +389 -0
- package/scripts/deploy-boot-smoke.mjs +116 -36
- package/scripts/deploy-boot-smoke.test.mjs +138 -17
- package/scripts/platform-repair.mjs +38 -2
- package/scripts/platform-repair.test.mjs +113 -0
- package/scripts/platform-sync.test.mjs +36 -9
- package/scripts/runner-fleet-consumers.json +20 -0
- package/scripts/semgrep-requirements.txt +169 -0
- package/templates/runbooks/runner-fleet-health.md +150 -0
- package/templates/workflows/deploy-staging-run.yml +117 -0
- package/templates/workflows/deploy-staging.yml +125 -64
|
@@ -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)
|
|
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
|
-
# >
|
|
11
|
-
# >
|
|
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
|
-
#
|
|
29
|
-
#
|
|
30
|
-
#
|
|
31
|
-
#
|
|
32
|
-
#
|
|
33
|
-
#
|
|
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>
|
|
51
|
-
#
|
|
52
|
-
#
|
|
53
|
-
#
|
|
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
|
-
#
|
|
67
|
-
#
|
|
68
|
-
# deploy-
|
|
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
|
-
|
|
75
|
-
name:
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
#
|
|
86
|
-
#
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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)."
|