Files
2026-07-13 12:58:18 +08:00

635 lines
34 KiB
YAML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
name: "Showcase: Promote (staging → prod)"
# Promotes the staging-tested digest of one or more services to prod.
# Workflow_dispatch only. Humans trigger. No automatic prod promotes.
#
# Order:
# 0. resolve-targets → expand the workflow_dispatch `service`
# input (SSOT key, dispatch_name, or
# 'all') into the canonical services_csv
# consumed by every downstream job.
# 1. verify-staging-precondition → live-probe staging for the target service(s).
# Refuse on red (matches bin/railway
# --require-staging-green default).
# 2. promote → bin/railway promote <service>; runs the
# spec §7 hardening (P1..P6).
# 3. verify-prod → verify-deploy.ts --env prod for the
# target service(s). Feature-level probes,
# not naked 200. Then the prod equivalence
# re-sweep gate (§6.2) and the pinned-ness
# gate (lint-prod, §8.2 — every prod service
# must be on an immutable @sha256: digest).
# 4. notify → Slack #oss-alerts on any red. Never #engr.
# success → #team-showcase.
on:
workflow_dispatch:
inputs:
service:
description: "Service to promote (dispatch_name or SSOT key). 'all' = whole fleet. Leave the placeholder to abort."
required: true
type: choice
# >>> BEGIN GENERATED service options (showcase/scripts/sync-promote-service-options.ts) — DO NOT EDIT
default: __select_a_service__
options:
- __select_a_service__
- all
- ag2
- agno
- built-in-agent
- claude-sdk-python
- claude-sdk-typescript
- crewai-crews
- google-adk
- langgraph-fastapi
- langgraph-python
- langgraph-typescript
- langroid
- llamaindex
- mastra
- ms-agent-dotnet
- ms-agent-harness-dotnet
- ms-agent-python
- pydantic-ai
- shell
- shell-dashboard
- shell-docs
- shell-dojo
- showcase-aimock
- showcase-harness
- showcase-pocketbase
- spring-ai
- starter-adk
- starter-agno
- starter-crewai-crews
- starter-langgraph-fastapi
- starter-langgraph-js
- starter-langgraph-python
- starter-llamaindex
- starter-mastra
- starter-ms-agent-framework-dotnet
- starter-ms-agent-framework-python
- starter-pydantic-ai
- starter-strands-python
- strands
- strands-typescript
- webhooks
# <<< END GENERATED service options
digest:
description: "Optional digest override (default: snapshot from staging)"
required: false
type: string
# Serialize ALL promotes on a single input-agnostic group so concurrent runs
# (e.g. `all` + a single-service promote) can't pin the same Railway service
# at once; `cancel-in-progress: false` ensures an in-flight prod promote is
# never cancelled by a newer run queued behind it.
concurrency:
group: showcase-promote
cancel-in-progress: false
permissions:
contents: read
jobs:
resolve-targets:
runs-on: ubuntu-latest
timeout-minutes: 3
permissions:
contents: read
outputs:
# Leaf-set CSV — the BACKWARD-COMPAT promote target the promote job still
# drives off (Phase 1 behavior is unchanged).
services_csv: ${{ steps.resolve.outputs.services_csv }}
# Tier-ordered promote closure (requested transitive runtimeDeps
# Tier-1 verification, §4.2). Phase 1 SURFACES it (operators see the
# closure in the step summary) but the actual promote still uses
# services_csv; U4 enforces tier ordering / dependent-gating off these.
closure_csv: ${{ steps.resolve.outputs.closure_csv }}
# Machine-readable `tier:name` form of the closure for U4 to consume.
closure_plan: ${{ steps.resolve.outputs.closure_plan }}
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
persist-credentials: false
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 22.x
- name: Generate SSOT artifact
working-directory: showcase/scripts
# The emitted JSON is EPHEMERAL here: resolve-promote-targets.sh reads it
# with jq to resolve which service(s) to promote and it is NEVER
# committed, so oxfmt-canonical formatting is irrelevant. This job's
# `npm ci` runs in showcase/scripts only and does NOT install the
# repo-root oxfmt binary the committed-artifact path shells out to, so
# leave it canonical-free via EMIT_SKIP_OXFMT (else spawnSync ENOENT
# fails the step and blocks EVERY promote). The committed-artifact path
# (static_quality.yml) keeps oxfmt REQUIRED + fail-loud.
env:
EMIT_SKIP_OXFMT: "1"
run: |
npm ci
npx tsx emit-railway-envs-json.ts
- name: Resolve target service set
id: resolve
# Resolution + the tiered-closure derivation live in the bats-tested
# showcase/scripts/resolve-promote-targets.sh so they can't drift from
# their test (see __tests__/resolve-promote-targets.bats), mirroring how
# promote-fleet.sh / verify-prod-display.sh were extracted from this same
# workflow. The script preserves the existing guards (--digest+all
# reject, empty-`all` fail-loud, unknown/ambiguous/not-prod-eligible) and
# emits services_csv (leaf, backward-compat) + closure_csv/closure_plan
# (tiered closure) into $GITHUB_OUTPUT, plus the closure plan + skips into
# $GITHUB_STEP_SUMMARY.
env:
INPUT: ${{ inputs.service }}
DIGEST: ${{ inputs.digest }}
GENERATED: showcase/scripts/railway-envs.generated.json
run: showcase/scripts/resolve-promote-targets.sh
verify-staging-precondition:
needs: [resolve-targets]
runs-on: ubuntu-latest
timeout-minutes: 15
environment: railway
permissions:
contents: read
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
persist-credentials: false
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 22.x
- working-directory: showcase/scripts
run: npm ci
- name: Live-probe staging for promote precondition
working-directory: showcase/scripts
env:
RAILWAY_TOKEN: ${{ secrets.RAILWAY_TOKEN }}
SERVICES_CSV: ${{ needs.resolve-targets.outputs.services_csv }}
run: |
if [ -z "$RAILWAY_TOKEN" ]; then
echo "::error::RAILWAY_TOKEN is not set"
exit 1
fi
# Spec §7.2 P3: the live staging probe at promote time is
# authoritative. CI verify history is a leading indicator only.
# Run from showcase/scripts (where `npm ci` installed tsx) so npx
# uses the local install instead of network-fetching it.
#
# --skip-ineligible: SERVICES_CSV is the PROMOTE target set, which
# for `service=all` legitimately includes services that are
# promotable but NOT staging-probe-eligible (the starter-* fleet
# carries probe.staging=false in the SSOT). Those are an expected
# state here, not a fault — verify-deploy SKIPS them with a clear
# status line and probes only the eligible services, rather than
# hard-crashing the whole precondition on the first starter-*. This
# flag does NOT relax green for eligible services, nor does it
# tolerate an unknown (non-SSOT) name — a typo is still a hard error.
npx tsx verify-deploy.ts --env staging --services "$SERVICES_CSV" --skip-ineligible
promote:
needs: [resolve-targets, verify-staging-precondition]
# Do NOT hard-depend on verify-staging-precondition's RESULT. That job
# probes the FULL requested set all-or-nothing, so a single staging-red
# service (e.g. a chronically-broken integration in `all`) fails it and
# — under the default `if: success()` — would SKIP promote entirely,
# re-blocking the whole fleet. bin/railway enforces staging-green
# PER-SERVICE (spec §7 P2 = latest staging deploy must be SUCCESS, P3 =
# live staging probe, default-on), so a red service is refused on its own
# and lands in promote-fleet.sh's failed set (reds the run) without
# taking the greens down with it. verify-staging-precondition therefore
# stays as an advisory early signal surfaced in the notify payload, not a
# hard blocker. `!cancelled()` keeps a human-cancelled run from promoting;
# mirrors the verify-prod gate idiom below.
if: ${{ !cancelled() && needs.resolve-targets.result == 'success' }}
runs-on: ubuntu-latest
# promote-fleet.sh now fans out promotes WITHIN a tier (PROMOTE_FANOUT, cap
# 5) instead of running the whole fleet serially. Even so, the largest tier
# (~30 integration leaves) at cap 5 with bin/railway's ~300s/service
# verify_serving_digest! is ~6 batches * ~5 min ≈ 30 min, so 20 min cancelled
# a `service=all` promote mid-fleet. 35 min leaves headroom above the
# worst-case tier without masking a genuinely stuck promote.
timeout-minutes: 35
environment: railway
permissions:
contents: read
packages: read
outputs:
# CSV of the services that ACTUALLY promoted (best-effort: a partial
# failure still exposes the succeeded subset). verify-prod scopes its
# prod verification to exactly this set so a single failed service can't
# red the verification of the services that did promote.
succeeded_csv: ${{ steps.promote.outputs.succeeded_csv }}
# Aggregated staging-drift payload: non-empty when any promoted service's
# staging RUNNING digest differed from the current GHCR :latest (i.e.
# staging was NOT serving :latest). Folded into the Slack notify payload
# so operators see "what shipped to prod is not current :latest".
staging_drift: ${{ steps.promote.outputs.staging_drift }}
# Base64-encoded results JSON (schema_version=1) carrying BOTH the
# succeeded[] and failed[] sets, consumed by the three-variant Slack
# renderer (showcase_promote_notify.yml). promote-fleet.sh is the SSOT
# for the result set; the notify job enriches this blob with run-context
# (run_id, trigger, operator, elapsed, pre_staging) before dispatch.
# Empty when the promote step did not run (the notify job synthesizes a
# total-failure blob in that case).
results_b64: ${{ steps.promote.outputs.results_b64 }}
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
persist-credentials: false
- uses: ruby/setup-ruby@d45b1a4e94b71acab930e56e79c6aa188764e7f9 # v1.316.0
with:
ruby-version: "3.3"
bundler-cache: false
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 22.x
- working-directory: showcase/scripts
# Same EPHEMERAL regeneration as resolve-targets: bin/railway reads this
# JSON to derive EXPECTED_DOMAINS and it is never committed, so skip the
# oxfmt-canonical pass (repo-root oxfmt is not installed by this job's
# showcase/scripts-scoped `npm ci`; an ENOENT here would abort promote).
env:
EMIT_SKIP_OXFMT: "1"
run: |
npm ci
npx tsx emit-railway-envs-json.ts
- name: bin/railway promote
id: promote
env:
RAILWAY_TOKEN: ${{ secrets.RAILWAY_TOKEN }}
GHCR_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Tier-ordered closure plan (U4): promote-fleet.sh prefers CLOSURE_PLAN
# over SERVICES_CSV, promoting BY TIER (0->1->2) with dependent-tier
# gating AND within-tier parallel fan-out (PROMOTE_FANOUT). A serial
# `service=all` fleet overran the job timeout; tiered fan-out cuts the
# wall-clock so the whole fleet completes inside timeout-minutes.
CLOSURE_PLAN: ${{ needs.resolve-targets.outputs.closure_plan }}
# Retained as the backward-compat fallback: promote-fleet.sh uses this
# ONLY when CLOSURE_PLAN is empty (e.g. an older resolve step).
SERVICES_CSV: ${{ needs.resolve-targets.outputs.services_csv }}
DIGEST: ${{ inputs.digest }}
run: |
set -euo pipefail
if [ -z "$RAILWAY_TOKEN" ]; then
echo "::error::RAILWAY_TOKEN is not set"
exit 1
fi
# promote-fleet.sh runs each service in turn BEST-EFFORT: a single
# red service (e.g. a chronically-broken integration in the `all`
# set) must not abort promotion of the rest of the fleet. The script
# attempts every service, aggregates the succeeded/failed sets, emits
# a step summary, and exits non-zero iff ANY service failed (so the
# notify job still fires) — but only AFTER attempting all of them.
# bin/railway itself handles spec §7 preconditions (P1..P6);
# --require-staging-green is default-on and the prior job already
# established staging is green (defense in depth). We deliberately do
# NOT pass --confirm-divergence: WARN-divergence refusals are a real
# signal that must fail the run.
RAILWAY_BIN="showcase/bin/railway" \
showcase/scripts/promote-fleet.sh
verify-prod:
needs: [resolve-targets, promote]
# Run whenever promote actually RAN — success OR partial failure — so the
# services that DID promote still get prod verification. `!cancelled()`
# excludes a human-cancelled run; `needs.promote.result != 'skipped'`
# excludes the case where promote never ran (e.g. an upstream abort/skip).
# Under the default `if: success()` this job was SKIPPED on any partial
# promote failure, leaving the promoted services with zero prod
# verification — that is the bug this gate fixes.
if: ${{ !cancelled() && needs.promote.result != 'skipped' }}
runs-on: ubuntu-latest
timeout-minutes: 20
environment: railway
permissions:
contents: read
outputs:
# Distinguishes a job that actually PROBED prod (`success`) from one that
# SKIPPED probing because nothing promoted (`skipped`). The GitHub job
# `result` is `success` in BOTH cases (the skip path exits 0), so the
# notify job must read THIS output — not `needs.verify-prod.result` — to
# avoid reporting a misleading `verify-prod=success` when prod was never
# touched. A real probe failure / contract violation exits non-zero, so
# the job `result` becomes `failure` and this output is never written
# (notify falls back to the job result for that case).
status: ${{ steps.verify.outputs.status }}
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
persist-credentials: false
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 22.x
- working-directory: showcase/scripts
run: npm ci
- name: Run verify-deploy --env prod
id: verify
working-directory: showcase/scripts
env:
RAILWAY_TOKEN: ${{ secrets.RAILWAY_TOKEN }}
# Scope verification to the services that ACTUALLY promoted (from the
# promote job's best-effort succeeded set), NOT the full requested set
# (resolve-targets) — verifying a service that failed to promote would
# guarantee a red verify and mask the health of the ones that did
# promote.
SERVICES_CSV: ${{ needs.promote.outputs.succeeded_csv }}
# The promote job's result, so the empty-CSV branch can tell a genuine
# all-failed run (empty succeeded set is expected) apart from a
# contract violation (promote reported success yet emitted no CSV).
PROMOTE_RESULT: ${{ needs.promote.result }}
run: |
set -euo pipefail
if [ -z "$RAILWAY_TOKEN" ]; then
echo "::error::RAILWAY_TOKEN is not set"
exit 1
fi
# If NOTHING promoted (every requested service failed, so the
# succeeded set is empty), there is nothing to verify — skip with a
# clear log line rather than calling verify-deploy.ts with an empty
# --services (which would either error or vacuously pass). The promote
# job already exited non-zero in that case, so the overall run is red
# via the notify state machine regardless.
#
# BUT: an empty succeeded set is only legitimate when promote did NOT
# succeed. If promote reported success and STILL emitted no CSV, the
# "promote already failed" assumption that justifies the vacuous skip
# is violated — fail loud instead of silently exiting 0.
if [ -z "$SERVICES_CSV" ]; then
if [ "$PROMOTE_RESULT" = "success" ]; then
echo "::error::promote reported success but succeeded_csv is empty — contract violation"
exit 1
fi
echo "::notice::succeeded_csv is empty (no services promoted, or promote did not run); skipping prod verification. The run is red via the promote job result if anything failed."
# Record that prod was SKIPPED, not verified. The job still exits 0
# (its `result` is `success`), so the notify job reads this `status`
# output to render `verify-prod=skipped` instead of a misleading
# `verify-prod=success`.
echo "status=skipped" >> "$GITHUB_OUTPUT"
exit 0
fi
# Run from showcase/scripts (where `npm ci` installed tsx) so npx uses
# the local install instead of network-fetching it. A non-zero exit
# here fails the step (job `result` = failure) and `status` is never
# written, so notify falls back to the job result.
npx tsx verify-deploy.ts --env prod --services "$SERVICES_CSV"
# Prod was actually probed and passed.
echo "status=success" >> "$GITHUB_OUTPUT"
# ── Prod equivalence gate (UNIT U10, spec §6.2) ──────────────────────
# After the closure pins (promote job) AND the per-service prod probe
# (above), re-sweep the promoted integration closure on the PROD control
# plane (prod harness scheduler + prod harness-workers, or scheduler
# INLINE fallback when workers are unprovisioned — §4.4) and run U9's
# equivalence gate over the FRESH prod rows vs current staging. Promote
# success flips to the equivalence definition (fail only on
# staging-green / prod-not-green, gray/stale excluded, one-directional).
#
# GATED ON CONFIGURATION: the equivalence gate needs prod + staging
# PocketBase creds and the prod Railway environment id. Those are an
# out-of-PR operational prerequisite (§8.1) — until the secrets are wired
# the step ANNOTATES "not configured" and is a no-op, so this job's
# existing per-service prod verification (above) remains the gate. Once
# configured, a gate FAILURE fails the step (and the run).
# Install the pnpm workspace ONLY when the equivalence gate is configured
# (the gate step's enqueue dynamically imports the harness producer graph,
# which lives in the pnpm workspace — `npm ci` in showcase/scripts alone
# does not resolve it). `--ignore-scripts` skips postinstall/Playwright
# browser downloads: the host only ENQUEUES + POLLS prod PocketBase; the
# prod harness-workers own the browser. Gated on the same config check as
# the gate step so the no-op (unconfigured) path stays cheap.
- name: Set up pnpm (equivalence gate only)
if: ${{ steps.verify.outputs.status == 'success' && vars.SHOWCASE_PROD_POCKETBASE_URL != '' && vars.SHOWCASE_STAGING_POCKETBASE_URL != '' && vars.SHOWCASE_RAILWAY_ENV_ID_PROD != '' }}
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
- name: pnpm install (equivalence gate only)
if: ${{ steps.verify.outputs.status == 'success' && vars.SHOWCASE_PROD_POCKETBASE_URL != '' && vars.SHOWCASE_STAGING_POCKETBASE_URL != '' && vars.SHOWCASE_RAILWAY_ENV_ID_PROD != '' }}
run: pnpm install --ignore-scripts
- name: Prod equivalence re-sweep gate
if: ${{ steps.verify.outputs.status == 'success' }}
working-directory: showcase/scripts
env:
# The promoted closure subset that ACTUALLY pinned (best-effort) —
# the set to re-sweep + compare. Same scope as the prod probe above.
PROMOTED_CLOSURE_CSV: ${{ needs.promote.outputs.succeeded_csv }}
RAILWAY_TOKEN: ${{ secrets.RAILWAY_TOKEN }}
RAILWAY_PROJECT_ID: ${{ vars.SHOWCASE_RAILWAY_PROJECT_ID }}
RAILWAY_ENVIRONMENT_ID_PROD: ${{ vars.SHOWCASE_RAILWAY_ENV_ID_PROD }}
PROD_POCKETBASE_URL: ${{ vars.SHOWCASE_PROD_POCKETBASE_URL }}
PROD_POCKETBASE_SUPERUSER_EMAIL: ${{ secrets.SHOWCASE_PROD_POCKETBASE_SUPERUSER_EMAIL }}
PROD_POCKETBASE_SUPERUSER_PASSWORD: ${{ secrets.SHOWCASE_PROD_POCKETBASE_SUPERUSER_PASSWORD }}
STAGING_POCKETBASE_URL: ${{ vars.SHOWCASE_STAGING_POCKETBASE_URL }}
STAGING_POCKETBASE_SUPERUSER_EMAIL: ${{ secrets.SHOWCASE_STAGING_POCKETBASE_SUPERUSER_EMAIL }}
STAGING_POCKETBASE_SUPERUSER_PASSWORD: ${{ secrets.SHOWCASE_STAGING_POCKETBASE_SUPERUSER_PASSWORD }}
# §4.4 annotation: flip to "false" once prod harness-workers are
# provisioned-but-degraded is the default-true full-throughput path.
PROD_HARNESS_WORKERS_PROVISIONED: ${{ vars.SHOWCASE_PROD_HARNESS_WORKERS_PROVISIONED }}
run: |
set -euo pipefail
# OUT-OF-PR PREREQ GATE: the equivalence gate is inert until the prod
# + staging PocketBase + prod Railway env-id config is wired (§8.1).
# Absence is NOT a failure — the per-service prod probe above already
# gated the run; this step just annotates that the equivalence gate
# was skipped for lack of configuration.
if [ -z "${PROD_POCKETBASE_URL:-}" ] || [ -z "${STAGING_POCKETBASE_URL:-}" ] || [ -z "${RAILWAY_ENVIRONMENT_ID_PROD:-}" ]; then
echo "::notice::Prod equivalence gate not configured (prod/staging PocketBase + prod Railway env-id absent) — skipping the re-sweep gate. This is the §8.1 out-of-PR prerequisite; the per-service prod probe remains the gate."
exit 0
fi
if [ -z "${PROMOTED_CLOSURE_CSV:-}" ]; then
echo "::notice::no promoted closure to re-sweep — equivalence gate vacuously passes."
exit 0
fi
# A gate FAILURE (a genuine staging-green / prod-not-green regression)
# or a re-sweep timeout (REFUSE) exits non-zero → fails this step and
# the run, exactly like the per-service probe above. `pnpm exec` (not
# `npx`) so the dynamically-imported harness producer graph resolves
# through the workspace node_modules the install step above created.
pnpm exec tsx verify-prod-resweep.ts
# ── Prod pinned-ness gate (UNIT U12, spec §8.2) ──────────────────────
# COMPLEMENTS the equivalence gate above — it asserts PINNED-NESS, not
# equivalence. After the promote pins the closure and prod is verified
# healthy + equivalent, assert that EVERY prod service is on an immutable
# `@sha256:` digest, not a mutable `:latest` tag. A born-on-:latest prod
# service (deploy-to-railway.ts provisions an unpinned source.image, spec
# R-E) can be healthy AND equivalent while still floating on a mutable tag
# — a latent rollback/repro hazard the other gates do not catch. The gate
# logic lives in the bats-tested showcase/scripts/lint-prod-gate.sh (a thin
# `bin/railway lint-prod` wrapper) so it can't drift from its test (see
# __tests__/lint-prod-gate.bats). Runs whenever prod was actually probed
# (same `status == 'success'` guard as the equivalence gate); a non-zero
# exit (an unpinned prod service, or a hard lint-prod error) fails the step
# and the run. No `--exit-zero`: an unpinned prod service must red the run.
- name: Prod pinned-ness gate (lint-prod)
if: ${{ steps.verify.outputs.status == 'success' }}
env:
RAILWAY_TOKEN: ${{ secrets.RAILWAY_TOKEN }}
run: |
set -euo pipefail
if [ -z "$RAILWAY_TOKEN" ]; then
echo "::error::RAILWAY_TOKEN is not set"
exit 1
fi
showcase/scripts/lint-prod-gate.sh
notify:
# Single Slack message per run via the three-variant aggregated renderer
# (.github/workflows/showcase_promote_notify.yml): success ✅ / partial ⚠️
# (per-service Failed bullets, cross-posts #oss-alerts) / total ❌. This job
# REPLACES the old inline two-state notify (success/failure-only, which
# dumped the full requested CSV and mislabeled a partial promote as a blanket
# "Failed [all 39]"). It builds the results JSON (from the promote job's
# results_b64, enriched with this run's context) and DISPATCHES the renderer.
#
# Best-effort promote invariant (do NOT reintroduce a PRE gate):
# resolve-targets = HARD precondition (must succeed).
# verify-staging-precondition = ADVISORY only — surfaced in the Slack
# payload (`pre_staging`), never gates
# promote or run success.
# promote = best-effort (exits non-zero iff a
# service failed).
# verify-prod = verifies the succeeded subset.
needs: [resolve-targets, verify-staging-precondition, promote, verify-prod]
if: always()
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
# The job's own GITHUB_TOKEN cannot start NEW workflow runs (Actions'
# recursion-prevention drops workflow_dispatch events authenticated with
# GITHUB_TOKEN), so the renderer dispatch goes through the devops-bot App
# token minted below — mirroring canary.yml's cross-workflow dispatch.
contents: read
# actions:read lets the payload step query this run's own metadata
# (created_at) to compute real wall-clock elapsed for the Slack message.
actions: read
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
persist-credentials: false
- name: Build notify payload
id: payload
env:
INPUT: ${{ inputs.service }}
# promote-fleet's results JSON (schema_version=1, succeeded[]+failed[]).
# Empty when the promote step did not run (upstream abort/skip/cancel).
RESULTS_B64: ${{ needs.promote.outputs.results_b64 }}
RESOLVE: ${{ needs.resolve-targets.result }}
PRE: ${{ needs.verify-staging-precondition.result }}
PROMOTE: ${{ needs.promote.result }}
PROD: ${{ needs.verify-prod.result }}
# Operator identity for the renderer's `operator_mention`. The renderer
# prefers a Slack users.lookupByEmail on operator_email; we have no
# reliable email for github.actor, so pass the actor as the git-name
# fallback (the renderer degrades to it when the email lookup is empty).
ACTOR: ${{ github.actor }}
# gh api (run metadata lookup for real wall-clock elapsed) needs a token.
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
# SKIP cases that must NOT post a Slack message (parity with the old
# neutral-state arms): a deliberate no-pick abort, or a human cancel.
if [ "$INPUT" = "__select_a_service__" ]; then
echo "::notice::no service selected (deliberate no-op abort); skipping Slack notify."
echo "dispatch=0" >> "$GITHUB_OUTPUT"
exit 0
fi
if [ "$RESOLVE" = "cancelled" ] || [ "$PRE" = "cancelled" ] || [ "$PROMOTE" = "cancelled" ] || [ "$PROD" = "cancelled" ]; then
echo "::notice::run cancelled by a human; skipping Slack notify (no red page)."
echo "dispatch=0" >> "$GITHUB_OUTPUT"
exit 0
fi
# Map the ADVISORY verify-staging-precondition result onto the
# renderer's pre_staging glyph vocabulary (green/amber/red/skipped).
case "$PRE" in
success) PRE_STAGING="green" ;;
failure) PRE_STAGING="amber" ;; # advisory red → amber (not a gate)
*) PRE_STAGING="skipped" ;;
esac
# 6-char lowercase hex run_id — the renderer's HARD CONTRACT
# (^[0-9a-f]{6}$; see its run-name directive). Derive it from this
# run's own id so it is stable + greppable, never random.
RUN_ID=$(printf '%06x' "$(( GITHUB_RUN_ID % 16777216 ))")
# Base results JSON: prefer promote-fleet's emitted blob (the SSOT for
# succeeded[]/failed[]). When promote never ran (no results_b64), the
# run aborted upstream — synthesize a total-failure blob so the
# renderer posts the ❌ variant instead of nothing.
if [ -n "${RESULTS_B64:-}" ]; then
if ! printf '%s' "$RESULTS_B64" | base64 -d > /tmp/results-base.json 2>/dev/null; then
echo "::error::failed to decode promote results_b64"
exit 1
fi
else
jq -nc '{schema_version:1, abort_reason:"fleet-preflight", succeeded:[], failed:[]}' > /tmp/results-base.json
fi
# Real wall-clock elapsed: this notify job runs last (needs: [...],
# if: always()), so (now - run.created_at) is the run's end-to-end
# duration. Query this run's own metadata via gh api (actions:read).
# Fall back to 0 only if the lookup fails or yields a non-sane value,
# in which case the renderer omits the "in {elapsed}" phrasing.
ELAPSED=0
created_at=$(gh api "repos/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" \
--jq '.created_at' 2>/dev/null || echo "")
if [ -n "$created_at" ]; then
start_epoch=$(date -u -d "$created_at" +%s 2>/dev/null || echo "")
if [ -n "$start_epoch" ]; then
now_epoch=$(date -u +%s)
delta=$(( now_epoch - start_epoch ))
# Guard against clock skew / parse glitches producing a negative.
if [ "$delta" -ge 0 ]; then
ELAPSED="$delta"
fi
fi
fi
# Enrich the base blob with this run's context (run_id, trigger,
# operator, elapsed, pre_staging). trigger=workflow (the renderer maps
# it to the `showcase_promote.yml` label). elapsed_seconds is the real
# wall-clock computed above; the renderer formats it as "Nm SSs".
ENRICHED_B64=$(jq -c \
--arg run_id "$RUN_ID" \
--arg trigger "workflow" \
--arg actor "$ACTOR" \
--arg pre "$PRE_STAGING" \
--argjson elapsed "$ELAPSED" \
'. + {run_id:$run_id, trigger:$trigger, operator_git_name:$actor, elapsed_seconds:$elapsed, pre_staging:$pre}' \
/tmp/results-base.json | base64 | tr -d '\n')
{
echo "dispatch=1"
echo "run_id=$RUN_ID"
echo "results_b64=$ENRICHED_B64"
} >> "$GITHUB_OUTPUT"
- name: Mint devops-bot token
id: app-token
if: steps.payload.outputs.dispatch == '1'
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
with:
app-id: 1108748
private-key: ${{ secrets.DEVOPS_BOT_PRIVATE_KEY }}
# actions=write is the ONLY scope needed: dispatch the sibling renderer
# workflow. The default GITHUB_TOKEN cannot start new workflow runs.
permission-actions: write
- name: Dispatch showcase_promote_notify.yml
if: steps.payload.outputs.dispatch == '1'
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
RESULTS_B64: ${{ steps.payload.outputs.results_b64 }}
RUN_ID: ${{ steps.payload.outputs.run_id }}
run: |
set -euo pipefail
# Dispatch the three-variant renderer. It owns ALL Slack posting
# (#team-showcase init + thread, #oss-alerts cross-post on
# partial/total). Pass trigger=workflow + the 6-hex run_id (which is
# also the renderer's run-name for the CLI's gh-run-list polling
# contract). Dispatched on the default branch (no --ref) so the
# renderer's reviewed main-branch definition runs.
gh workflow run showcase_promote_notify.yml \
--repo "$GITHUB_REPOSITORY" \
-f results="$RESULTS_B64" \
-f trigger=workflow \
-f run_id="$RUN_ID"
echo "dispatched showcase_promote_notify.yml (run_id=$RUN_ID)"