chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,634 @@
|
||||
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)"
|
||||
Reference in New Issue
Block a user