mandrel-platform 0.20.1 → 0.24.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 +1 -1
- package/scripts/check-ci-required-aggregator.test.mjs +227 -0
- package/scripts/check-coverage-threshold.test.mjs +56 -51
- package/scripts/check-destructive-migration.mjs +68 -6
- package/scripts/check-destructive-migration.test.mjs +146 -0
- package/scripts/check-runner-health.mjs +469 -0
- package/scripts/check-runner-health.test.mjs +389 -0
- package/scripts/deploy-boot-smoke.mjs +364 -0
- package/scripts/deploy-boot-smoke.test.mjs +381 -0
- package/scripts/deploy-worker-secrets.mjs +190 -0
- package/scripts/deploy-worker-secrets.test.mjs +136 -0
- package/scripts/platform-sync.test.mjs +36 -9
- package/scripts/runner-fleet-consumers.json +20 -0
- package/templates/runbooks/README.md +13 -0
- package/templates/runbooks/runner-fleet-health.md +150 -0
- package/templates/runbooks/runner-provisioning.md +187 -0
- package/templates/runner/.env.example +45 -0
- package/templates/runner/job-cleanup.sh +97 -0
- package/templates/workflows/deploy-staging-run.yml +77 -0
- package/templates/workflows/deploy-staging.yml +67 -62
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
#
|
|
3
|
+
# ACTIONS_RUNNER_HOOK_JOB_STARTED hook — generalized, runner-scoped hygiene
|
|
4
|
+
# for PERSISTENT self-hosted runners (mandrel-platform runner kit).
|
|
5
|
+
#
|
|
6
|
+
# A persistent runner (launchd/systemd service) leaks state from a prior job
|
|
7
|
+
# into the next one: the runner does not always reap the job's child process
|
|
8
|
+
# tree, and some actions leave files in shared locations. Observed breakage
|
|
9
|
+
# classes this hook guards against:
|
|
10
|
+
#
|
|
11
|
+
# - an orphaned `pnpm`/`node` process (e.g. a hung install or lint) still
|
|
12
|
+
# mutating the pnpm shim install, corrupting the pnpm CLI for the next job;
|
|
13
|
+
# - leftover `gitleaks.tmp` / `gitleaks-*` artifacts in the shared $TMPDIR
|
|
14
|
+
# blocking the next gitleaks download.
|
|
15
|
+
#
|
|
16
|
+
# Running this before every job gives each job a clean slate ("fresh per job"
|
|
17
|
+
# without the cost of re-registering an ephemeral runner).
|
|
18
|
+
#
|
|
19
|
+
# ── CONCURRENCY SAFETY (the load-bearing design constraint) ─────────────────
|
|
20
|
+
#
|
|
21
|
+
# Multiple runners on one host typically run as the SAME OS user, so anything
|
|
22
|
+
# under $HOME (notably `~/setup-pnpm`, the pnpm/action-setup DEFAULT install
|
|
23
|
+
# destination) is SHARED across all co-resident runners. A hook that reaps
|
|
24
|
+
# processes matching `~/setup-pnpm` or `rm -rf`s it will destroy a pnpm
|
|
25
|
+
# install a CONCURRENT runner is mid-flight on. This hook therefore:
|
|
26
|
+
#
|
|
27
|
+
# 1. NEVER touches `~/setup-pnpm` or any other $HOME-shared pnpm path.
|
|
28
|
+
# The pnpm shim MUST instead be runner-scoped at install time: the
|
|
29
|
+
# platform's `setup-toolchain` composite action already defaults
|
|
30
|
+
# pnpm/action-setup's `dest` to `${{ runner.temp }}/pnpm` (i.e.
|
|
31
|
+
# `<RUNNER_DIR>/_work/_temp/pnpm`, unique per runner), and
|
|
32
|
+
# `pr-quality.yml` exposes a `pnpm-dest` input for explicit overrides.
|
|
33
|
+
# See templates/runbooks/runner-provisioning.md § "pnpm scoping".
|
|
34
|
+
# 2. Reaps ONLY processes whose command line resolves inside THIS runner's
|
|
35
|
+
# own work tree (`<RUNNER_DIR>/_work/...`). Every path below derives
|
|
36
|
+
# from RUNNER_DIR, which is unique per runner, so a co-resident
|
|
37
|
+
# runner's processes and files are never matched.
|
|
38
|
+
# 3. Age-gates cleanup of the genuinely shared $TMPDIR gitleaks artifacts,
|
|
39
|
+
# so a fresh (in-flight) download owned by a concurrent job is never
|
|
40
|
+
# deleted — only stale leftovers are.
|
|
41
|
+
#
|
|
42
|
+
# ── PARAMETERIZATION ────────────────────────────────────────────────────────
|
|
43
|
+
#
|
|
44
|
+
# No hardcoded usernames, repo names, or runner names. All paths derive from:
|
|
45
|
+
#
|
|
46
|
+
# RUNNER_DIR — the runner's root directory. Defaults to the directory
|
|
47
|
+
# containing this script (the kit installs the hook into the
|
|
48
|
+
# runner root, next to config.sh / run.sh). Override via env
|
|
49
|
+
# only if you install the hook elsewhere.
|
|
50
|
+
# RUNNER_TMP — the runner's per-runner job temp (`runner.temp`), always
|
|
51
|
+
# `${RUNNER_DIR}/_work/_temp`.
|
|
52
|
+
# JOB_CLEANUP_STALE_MINUTES
|
|
53
|
+
# — age threshold (minutes) for the shared-$TMPDIR gitleaks
|
|
54
|
+
# sweep. Default 60. Artifacts younger than this are assumed
|
|
55
|
+
# in-flight and left alone.
|
|
56
|
+
#
|
|
57
|
+
# Configured via `ACTIONS_RUNNER_HOOK_JOB_STARTED=<RUNNER_DIR>/job-cleanup.sh`
|
|
58
|
+
# in the runner's `.env` (see .env.example in this directory).
|
|
59
|
+
#
|
|
60
|
+
# NEVER fails the job — best-effort cleanup, always exits 0.
|
|
61
|
+
|
|
62
|
+
set +e
|
|
63
|
+
|
|
64
|
+
RUNNER_DIR="${RUNNER_DIR:-$(cd "$(dirname "$0")" && pwd)}"
|
|
65
|
+
RUNNER_WORK="${RUNNER_DIR}/_work"
|
|
66
|
+
RUNNER_TMP="${RUNNER_WORK}/_temp"
|
|
67
|
+
TMP="${TMPDIR:-/tmp}"
|
|
68
|
+
STALE_MINUTES="${JOB_CLEANUP_STALE_MINUTES:-60}"
|
|
69
|
+
|
|
70
|
+
# 1) Reap orphaned pnpm/node processes from prior jobs — scoped to THIS
|
|
71
|
+
# runner's work tree only. The patterns target executable paths INSIDE the
|
|
72
|
+
# runner-scoped install locations (`.../node_modules`), so they match the
|
|
73
|
+
# actual pnpm/node binaries that ran from these dirs — not a shell that
|
|
74
|
+
# merely references the path. RUNNER_TMP and RUNNER_WORK are unique per
|
|
75
|
+
# runner, so co-resident runners and unrelated user processes are never
|
|
76
|
+
# hit. The shared `~/setup-pnpm` is deliberately NOT a reap target (see
|
|
77
|
+
# the concurrency-safety header).
|
|
78
|
+
pkill -9 -f "${RUNNER_TMP}/pnpm/node_modules" 2>/dev/null
|
|
79
|
+
pkill -9 -f "${RUNNER_TMP}/setup-pnpm/node_modules" 2>/dev/null
|
|
80
|
+
pkill -9 -f "${RUNNER_WORK}/_tool/[^ ]*node_modules" 2>/dev/null
|
|
81
|
+
|
|
82
|
+
# 2) Remove stale runner-scoped pnpm shim installs so the next job's
|
|
83
|
+
# pnpm/action-setup starts from a clean slate. Only paths under THIS
|
|
84
|
+
# runner's `_work/_temp` are deleted — never `~/setup-pnpm`.
|
|
85
|
+
rm -rf "${RUNNER_TMP}/pnpm" 2>/dev/null
|
|
86
|
+
rm -rf "${RUNNER_TMP}/setup-pnpm" 2>/dev/null
|
|
87
|
+
|
|
88
|
+
# 3) Sweep stale gitleaks artifacts from the SHARED $TMPDIR. Because this
|
|
89
|
+
# location is shared by every runner on the host, deletion is age-gated:
|
|
90
|
+
# only artifacts older than STALE_MINUTES are removed, so a concurrent
|
|
91
|
+
# runner's in-flight download is never deleted mid-job.
|
|
92
|
+
find "${TMP}" -maxdepth 1 -name 'gitleaks.tmp' -mmin "+${STALE_MINUTES}" \
|
|
93
|
+
-exec rm -f {} + 2>/dev/null
|
|
94
|
+
find "${TMP}" -maxdepth 1 -name 'gitleaks-*' -mmin "+${STALE_MINUTES}" \
|
|
95
|
+
-exec rm -rf {} + 2>/dev/null
|
|
96
|
+
|
|
97
|
+
exit 0
|
|
@@ -0,0 +1,77 @@
|
|
|
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: only the freshest dispatch should reach the
|
|
55
|
+
# staging surfaces. The shared deploy-cloudflare.yml additionally serializes
|
|
56
|
+
# per-environment.
|
|
57
|
+
concurrency:
|
|
58
|
+
group: deploy-staging-run
|
|
59
|
+
cancel-in-progress: true
|
|
60
|
+
|
|
61
|
+
jobs:
|
|
62
|
+
deploy:
|
|
63
|
+
name: Staging deploy (shared deploy-cloudflare.yml)
|
|
64
|
+
uses: dsj1984/mandrel-platform/.github/workflows/deploy-cloudflare.yml@<MANDREL_PLATFORM_SHA> # <MANDREL_PLATFORM_TAG>
|
|
65
|
+
with:
|
|
66
|
+
environment: staging
|
|
67
|
+
gh-environment: staging
|
|
68
|
+
workers: <WORKERS_CSV>
|
|
69
|
+
migrate: true
|
|
70
|
+
# db-engine defaults to 'd1'. Set db-engine + migrate-command +
|
|
71
|
+
# snapshot-command for a non-D1 engine (e.g. Turso) — see the contract
|
|
72
|
+
# doc's "command seams" section.
|
|
73
|
+
# Frozen secret allowlist: only {CLOUDFLARE_*, TURSO_*} cross into the
|
|
74
|
+
# shared workflow. Map your project's secret NAMES onto these slots.
|
|
75
|
+
secrets:
|
|
76
|
+
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
|
77
|
+
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
|
@@ -1,86 +1,91 @@
|
|
|
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
|
-
# > audit -> CF env gate ->
|
|
7
|
-
# > boot-smoke + auto-rollback)
|
|
24
|
+
# > audit -> CF env gate -> migration (snapshot + apply) -> deploy ->
|
|
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 is now a `require-ci-green`
|
|
20
|
-
# job INSIDE `deploy-cloudflare.yml` itself (`github.event` inside a reusable
|
|
21
|
-
# workflow is the CALLER's event, so the shared workflow can see and gate on
|
|
22
|
-
# the `workflow_run` conclusion even though it cannot own this file's `on:`
|
|
23
|
-
# block). This template needs NO caller-side preflight guard as a result —
|
|
24
|
-
# 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.
|
|
25
31
|
#
|
|
26
|
-
#
|
|
27
|
-
#
|
|
28
|
-
#
|
|
29
|
-
#
|
|
30
|
-
#
|
|
31
|
-
#
|
|
32
|
-
# <MANDREL_PLATFORM_SHA> the pinned mandrel-platform commit SHA (resolve
|
|
33
|
-
# via `node scripts/platform-sync.mjs --ref
|
|
34
|
-
# <release-tag>` from the consumer repo root, or
|
|
35
|
-
# hand-resolve via `git ls-remote`).
|
|
36
|
-
# <MANDREL_PLATFORM_TAG> the human-readable release tag matching the SHA
|
|
37
|
-
# above (trailing `# <tag>` comment).
|
|
38
|
-
# <WORKERS_CSV> comma-separated Worker names for this env, e.g.
|
|
39
|
-
# "api,web".
|
|
40
|
-
# <BUILD_COMMAND> optional build command (omit build-command /
|
|
41
|
-
# build-artifact entirely if the deploy job's
|
|
42
|
-
# 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.
|
|
43
38
|
#
|
|
44
39
|
# See the full input/secret contract:
|
|
45
40
|
# https://github.com/dsj1984/mandrel-platform/blob/main/docs/reusable-workflows.md#deploy-cloudflareyml
|
|
46
41
|
|
|
47
42
|
on:
|
|
48
|
-
# CI-green gate: fires when <CI_WORKFLOW_NAME>
|
|
49
|
-
# deploy
|
|
50
|
-
#
|
|
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.
|
|
51
49
|
workflow_run:
|
|
52
50
|
workflows: [<CI_WORKFLOW_NAME>]
|
|
53
51
|
branches: [main]
|
|
54
52
|
types: [completed]
|
|
55
|
-
# Manual on-demand trigger (UI "Run workflow" + `gh workflow run`).
|
|
56
|
-
# workflow_dispatch always passes the shared workflow's CI-green guard
|
|
57
|
-
# (operator-intentional, no upstream conclusion to gate on).
|
|
58
|
-
workflow_dispatch:
|
|
59
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.
|
|
60
57
|
permissions:
|
|
61
58
|
contents: read
|
|
59
|
+
actions: write
|
|
62
60
|
|
|
63
|
-
#
|
|
64
|
-
#
|
|
65
|
-
# 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.)
|
|
66
65
|
concurrency:
|
|
67
|
-
group: deploy-staging
|
|
66
|
+
group: deploy-staging-dispatch
|
|
68
67
|
cancel-in-progress: true
|
|
69
68
|
|
|
70
69
|
jobs:
|
|
71
|
-
|
|
72
|
-
name:
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
70
|
+
dispatch:
|
|
71
|
+
name: Dispatch staging deploy on CI-green
|
|
72
|
+
runs-on: ubuntu-latest
|
|
73
|
+
timeout-minutes: 5
|
|
74
|
+
# CI-green gate: dispatch the deploy ONLY when the upstream CI run
|
|
75
|
+
# concluded 'success'. `workflow_run` fires on both success and failure, so
|
|
76
|
+
# this guard is load-bearing — without it a red main would still deploy.
|
|
77
|
+
if: ${{ github.event.workflow_run.conclusion == 'success' }}
|
|
78
|
+
steps:
|
|
79
|
+
- name: Dispatch deploy-staging-run.yml (workflow_dispatch)
|
|
80
|
+
env:
|
|
81
|
+
GH_TOKEN: ${{ github.token }}
|
|
82
|
+
REPO: ${{ github.repository }}
|
|
83
|
+
SHA: ${{ github.event.workflow_run.head_sha }}
|
|
84
|
+
shell: bash
|
|
85
|
+
run: |
|
|
86
|
+
set -euo pipefail
|
|
87
|
+
gh workflow run deploy-staging-run.yml \
|
|
88
|
+
--repo "${REPO}" \
|
|
89
|
+
--ref main \
|
|
90
|
+
-f sha="${SHA}"
|
|
91
|
+
echo "Dispatched staging deploy for ${SHA} (deploy runs on workflow_dispatch so environment: jobs execute)."
|