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 ; 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)"