Skip to content

main reset: post-stable v0.77.1 (#4778) #5693

main reset: post-stable v0.77.1 (#4778)

main reset: post-stable v0.77.1 (#4778) #5693

name: Select beta to promote
# Scheduled gatekeeper on inkeep/open-knowledge that SELECTS the most-recently
# soak-proven beta to promote to stable and dispatches promote-stable.yml with
# it, but only inside the US business-hours window (9am ET .. 4pm PT, Mon-Fri).
# It does SELECTION ONLY — it computes no version. promote-stable.yml owns
# version determination (scripts/compute-stable-version.mjs), so manual and auto
# promotions share one source of version truth.
#
# "Latest soaked" (not simply "newest"): it walks the beta tags newest -> oldest
# in the established version order and selects the first that is unshipped, fully
# cut, and >= 24h online. A fresher beta cut < 24h ago is skipped in favor of the
# previous soaked beta; the skipped beta's changeset rolls forward — promote-
# stable batches the whole delta over the latest stable into one bump, so nothing
# is lost. The descent stops at the first already-shipped beta (its commit is
# contained in the latest stable), so it never reaches back across a shipped
# boundary. This selection logic lives in
# .github/scripts/select-beta-to-promote.mjs (unit-tested under `vitest run`,
# `.github/scripts`), not inline bash — it is the sole safety gate for automated
# selection (see NOTE below), so it is fail-loud and test-covered.
#
# This workflow does NOT reimplement promotion. It dispatches promote-stable.yml
# with an EXPLICIT beta_tag (the selected beta); promote-stable computes the
# stable version from the changeset delta over the latest stable, tags it,
# creates the Release, and cascades npm --tag latest + signed stable DMG +
# main-reset unchanged. "OK desktop to stable" is covered for free via the
# promote-stable -> desktop-release cascade (latest-mac.yml + DMG). NOTE:
# promote-stable's fully-cut guard runs only on its blank/auto path, so the
# explicit dispatch bypasses it; this script performs the unshipped + fully-cut
# checks itself, and fails loud (job error, retry next tick) on any non-404
# gh/git error rather than folding an infra failure into a select/no-op decision.
#
# Why a schedule is the backbone (not push alone): eligibility here is reached
# by the passage of TIME (a beta crossing 24h old), not by a commit. During a
# quiet stretch with no pushes an eligible beta would never promote if we only
# ran on commits. The cron is what notices "24h has now elapsed"; the push
# trigger is an additive fast-path so an already-eligible beta promotes promptly
# when a Copybara mirror commit lands in-hours.
#
# Weekend latency: the in-script gate is Mon-Fri, so a beta that crosses 24h
# over the weekend promotes on the next weekday tick — a Friday-published beta
# can be up to ~71h online before its Monday-morning promotion. This is the
# intended "no weekend promotions" behavior for the standard soak tier;
# workflow_dispatch (which bypasses the hours gate) is the escape hatch for an
# urgent out-of-hours promotion.
#
# SOAK TIERS. Two are defined; only one is reachable today.
# standard — 24h online, dispatched inside the window above. Every promotion
# uses this.
# fast — 1h online, around the clock, for a cut that is BOTH patch-only
# AND carries a linked bug fix. Both conjuncts are load-bearing:
# patch-only alone covers most stable transitions, so on its own it
# would be a blanket soak reduction rather than a bug lane.
# This is a different axis from the selection `tier` output, which reports how
# the 24h selection itself was made. The soak-tier verdict and the DMG-smoke
# leg ARE wired together: a qualifying verdict nominates the beta as
# `fast_tier_candidate`, and the smoke leg is the only dispatcher for it.
#
# The fast SOAK tier is ARMED, smoke-gated. With `FAST_TIER_ARMED` "true", a
# qualifying cut (patch-only + bug-linked delta, fully cut, >=1h soak) is
# nominated as `fast_tier_candidate`, and the macOS smoke leg below — the ONLY
# dispatcher for fast promotions — smokes the actual DMG before dispatching
# promote-stable, around the clock. The hours-gated direct dispatch still
# promotes only the 24h selection; SOAK_SECONDS stays 86400 on that path. A
# smoke refusal pages Slack and leaves the beta on the normal soak. Disarming
# is one edit back to "false" — the "Soak tiers" section of RELEASES.md is the
# operator-facing record.
#
# DST correctness: GitHub Actions cron is UTC and ignores daylight saving, so a
# hard-coded "9am ET" would drift an hour twice a year. The cron therefore does
# not encode the window at all — it fires around the clock, and the
# AUTHORITATIVE business-hours decision is made in-script against the
# America/New_York clock (see the "Business-hours gate" step), so the window
# stays exact through DST transitions. 4pm PT == 7pm ET == 19:00 ET, so the ET
# window is 09:00 (inclusive) .. 19:00 (exclusive).
#
# Concurrency + de-dupe: this evaluator carries its own group (distinct from the
# ok-release-cadence lock that release.yml / promote-stable.yml share) so it
# never blocks a real cut. Double-promotion is prevented three ways:
# (1) the concurrency group serializes ticks (one running, newest pending —
# see the concurrency block for why in-flight runs are never cancelled),
# (2) a fail-loud in-flight
# check skips when a promote-stable run is already in_progress/queued, and
# (3) the script stops at the first already-shipped beta (commit contained in the
# latest stable) and never selects one whose changes are already released.
#
# FAST TIER (FR5a), BUILT BUT UNARMED: a second job smokes an under-soaked
# beta's DMG and, only on a clean smoke, dispatches promote-stable early. It is
# gated on `fast_tier_candidate`, which the selection script writes empty today,
# so it never runs and adds zero per-tick cost. A fail or an error verdict only
# ever declines the early promotion — the 24h path is untouched and the gate can
# never block a release. Arming steps are documented in RELEASES.md.
on:
schedule:
# Every 10 minutes, around the clock. A strict superset of the previous
# hourly :17 weekday-band tick, so nothing that used to fire stops firing.
# Two reasons for the widening: a 1h fast-tier soak only means ~1h if the
# evaluator looks more often than hourly, and the soak-tier decision has to
# be observable at every hour of every day, not only inside the window. The
# offsets stay off :00 to avoid the scheduler spike.
#
# Cron here is UTC and DST-blind BY DESIGN — it no longer encodes the window
# at all. The AUTHORITATIVE business-hours decision is still made in-script
# against the America/New_York clock (see the "Business-hours gate" step), so
# the window stays exact through DST transitions and an out-of-window tick is
# a clean evaluate-and-no-op.
- cron: "7,17,27,37,47,57 * * * *"
# Fast-path: re-evaluate the gate when Copybara mirrors a commit to main so an
# already-eligible beta promotes promptly in-hours. Additive to the schedule;
# the same in-script business-hours + soak gate applies.
push:
branches: [main]
workflow_dispatch:
inputs:
dry_run:
description: "Evaluate the gate and log the decision without dispatching promote-stable."
required: false
type: boolean
default: false
permissions:
# actions:write is required to dispatch promote-stable.yml (workflow_dispatch
# via the API) and covers the read for the in-flight `gh run list` check.
# contents:read to read tags. No id-token and no App token here — promote-stable
# mints its own App token for the identity-asserting work (tag push, cross-repo
# main-reset dispatch), so this evaluator stays minimally scoped.
contents: read
actions: write
concurrency:
# Deliberately NOT the shared ok-release-cadence group — a read-only evaluator
# must never queue behind (or delay) a real beta cut / stable promotion.
#
# cancel-in-progress is FALSE, and the reason is the smoke leg. "A cancelled
# run loses nothing" was true of the evaluation but not of an in-flight DMG
# smoke: cancelling one un-nominates a QUALIFIED candidate, and the next tick
# does not retry it — by then a newer (usually minor) beta has crossed the
# fast soak and shadows the patch candidate for the rest of the cycle. On a
# busy push day that cancels the fast tier exactly when it is wanted; the
# first live qualification died this way, mid-smoke, and the aggregate alarm
# then paged about it for hours. Queueing gives at most one pending tick
# behind a bounded run (all jobs carry timeout-minutes), which GitHub
# collapses to the newest — freshness without killing gates mid-answer.
group: ok-auto-promote-stable
cancel-in-progress: false
env:
# THE fast-tier arming switch, read by both the selector and the aggregate
# alarm. Hoisted to one place because the two must agree: the alarm's whole
# premise is "the tier is armed and reaching nothing", which is only a real
# finding when the tier is in fact armed. Two literals would drift and the
# alarm would page about a tier that is switched off.
# Armed 2026-07-31 by owner decision after the prerequisites in the "Soak
# tiers" section of RELEASES.md were met (smoke leg wired as the only fast
# dispatcher; per-refusal Slack paging; point-release revert lane proven).
# Disarming is flipping this back to "false" in a reviewed PR.
FAST_TIER_ARMED: "true"
jobs:
evaluate:
name: Evaluate 24h soak + business-hours gate
runs-on: ubuntu-latest
timeout-minutes: 10
outputs:
# Seam for the FR5a fast tier: the armed soak-tier verdict nominates a
# qualifying beta here (patch-only + bug-linked, fully cut, >=1h soak) and
# the macOS smoke leg keys off it. Empty on ticks where nothing qualifies,
# which skips the smoke leg at zero cost — and empty on every tick when
# FAST_TIER_ARMED is "false".
fast_tier_candidate: ${{ steps.select.outputs.fast_tier_candidate }}
tier: ${{ steps.select.outputs.tier }}
steps:
- name: Business-hours gate (America/New_York)
id: hours
env:
EVENT_NAME: ${{ github.event_name }}
run: |
set -euo pipefail
# Manual runs bypass the business-hours gate so an operator can test the
# soak logic at any time; schedule + push obey the window.
if [[ "$EVENT_NAME" == "workflow_dispatch" ]]; then
echo "workflow_dispatch — bypassing business-hours gate (soak + guards still apply)."
echo "open=true" >> "$GITHUB_OUTPUT"
exit 0
fi
DOW=$(TZ="America/New_York" date +%u) # 1=Mon .. 7=Sun
HOUR=$(TZ="America/New_York" date +%H) # 00 .. 23
HOUR=$((10#$HOUR)) # strip leading zero (octal-safe)
# Window: weekdays, 09:00 ET (inclusive) .. 19:00 ET (exclusive).
# 19:00 ET == 16:00 PT == 4pm PT, the requested upper bound.
if (( DOW >= 1 && DOW <= 5 )) && (( HOUR >= 9 && HOUR < 19 )); then
echo "In business hours (ET: DOW=$DOW HOUR=$HOUR)."
echo "open=true" >> "$GITHUB_OUTPUT"
else
echo "Outside business hours (ET: DOW=$DOW HOUR=$HOUR; window Mon-Fri 09:00-18:59 ET = 9am ET .. 4pm PT). No-op."
echo "open=false" >> "$GITHUB_OUTPUT"
fi
# Checkout / setup-node / select deliberately carry NO hours gate. The
# soak-tier decision has to be computed and logged on every tick,
# including outside the window, and gating the selector on the clock
# would make that impossible. Only the dispatch step below is gated, so
# an out-of-window tick evaluates and then does nothing.
- name: Checkout (need full tag history for beta resolution)
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
# Read-only job: it resolves tags with git and talks to GitHub via a
# separate GH_TOKEN; it never pushes, so don't persist GITHUB_TOKEN into
# the runner's .git/config (least privilege for a release-dispatching job).
persist-credentials: false
- name: Set up Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: "24"
- name: Select the beta to promote
id: select
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
SOAK_SECONDS: "86400" # 24h online soak — the ONLY soak that can set target
# THE ARMING SWITCH, and the only edit that turns the fast soak tier
# on. While this is "false" the script computes and logs the tier it
# would choose but always returns soak_tier=standard, so promotion
# keeps using the 24h soak above and this workflow behaves exactly as
# it did before the tier existed. Flipping it to "true" requires a
# reviewed PR — read the "Soak tiers" section of RELEASES.md first:
# arming has prerequisites, and it is an owner decision.
FAST_TIER_ARMED: ${{ env.FAST_TIER_ARMED }}
# Optional. Absent (the current state) the bug-link lookup reports
# "unresolvable" and the tier degrades to standard, which is the
# expected steady state until an operator provisions the secret.
LINEAR_API_KEY: ${{ secrets.LINEAR_API_KEY }}
# Writes target=, tier=, soak_tier= (plus the predicate inputs) to
# GITHUB_OUTPUT; an empty target => no-op. Exits non-zero (fails the job)
# on any non-404 gh/git infra error — a fail-loud retry-next-tick, never
# a silent skip. The soak-tier predicate is the one deliberate
# exception: it degrades to the slower tier rather than failing the tick.
# Logic + tests: .github/scripts/select-beta-to-promote.{mjs,test.mjs}.
run: node .github/scripts/select-beta-to-promote.mjs
# Hours-gated, standard tier only. `target` is always the 24h selection —
# a fast-tier cut never reaches this step; it is nominated as
# `fast_tier_candidate` and promotes (around the clock) only through the
# macOS smoke leg below, after its DMG smokes clean. Letting soak_tier
# reopen this gate would promote a 1h cut with no artifact check.
- name: Guard against an in-flight promotion, then dispatch
if: steps.hours.outputs.open == 'true' && steps.select.outputs.target != ''
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TARGET: ${{ steps.select.outputs.target }}
# workflow_dispatch supplies the boolean; schedule/push leave inputs
# null, so default to "false" (never a dry run on the automated path).
DRY_RUN: ${{ github.event.inputs.dry_run || 'false' }}
run: |
set -euo pipefail
# Don't double-fire while a promote-stable run is already active — covers
# the window between dispatch and promote-stable pushing the stable tag
# (after which the script's stop-at-already-shipped catches it). Fail
# loud on a query error: an evaluator that cannot confirm "no promotion
# in flight" must abort and retry next tick, not assume zero (no
# `|| echo 0`).
active=$(gh run list --workflow=promote-stable.yml --json status \
--jq '[.[] | select(.status=="in_progress" or .status=="queued")] | length')
if [[ "$active" -gt 0 ]]; then
echo "::notice::A promote-stable run is already in_progress/queued (${active}); skipping to avoid a duplicate promotion."
exit 0
fi
if [[ "$DRY_RUN" == "true" ]]; then
echo "::notice::DRY RUN: would dispatch promote-stable.yml with beta_tag=${TARGET} (promote-stable computes the stable version)."
exit 0
fi
# Explicit beta_tag (not blank): a blank dispatch auto-resolves the NEWEST
# beta, but under "latest soaked" the target may sit below the head.
# promote-stable's explicit-tag path skips its own fully-cut guard, so this
# script's unshipped + fully-cut checks are the safety net for this dispatch.
SELF_URL="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}"
SINCE=$(date -u +%Y-%m-%dT%H:%M:%SZ)
gh workflow run promote-stable.yml -f beta_tag="$TARGET" -f dispatched_by="$SELF_URL"
echo "::notice::Dispatched promote-stable for ${TARGET} (it computes the stable version from the changeset delta, then npm --tag latest + stable DMG + main-reset)."
# Link this run to the promote-stable run it just created. `gh workflow
# run` does not return the new run id, so resolve it (best-effort) as the
# newest workflow_dispatch run of promote-stable created after SINCE.
CHILD=""
for _ in 1 2 3 4 5 6 7 8; do
CHILD=$(gh run list --workflow=promote-stable.yml --event=workflow_dispatch --limit 8 --json url,createdAt \
--jq "[.[] | select(.createdAt >= \"$SINCE\")] | sort_by(.createdAt) | last | .url // empty" 2>/dev/null || true)
[[ -n "$CHILD" ]] && break
sleep 3
done
{
echo "### Dispatched promote-stable"
echo "- Beta: \`${TARGET}\`"
if [[ -n "$CHILD" ]]; then echo "- Run: ${CHILD}"; else echo "- (run link unresolved; open the promote-stable workflow)"; fi
} >> "$GITHUB_STEP_SUMMARY"
if [[ -n "$CHILD" ]]; then echo "::notice::promote-stable run: ${CHILD}"; fi
# FR5a — selection-time DMG smoke for the fast tier: the ONLY dispatcher for
# fast-tier promotions, running around the clock (no business-hours gate).
#
# Runs only on ticks where the armed soak-tier verdict nominated a candidate
# (`fast_tier_candidate` non-empty); every other tick skips it at zero cost.
#
# This job NEVER blocks a release. It cannot fail the 24h path: the evaluate
# job has already dispatched (or not) by the time this runs, on its own
# decision, and this job only ever ADDS an early promotion. A fail or an error
# verdict simply means no fast-tier dispatch happens and the beta waits out
# its normal 24h soak on a later tick.
#
# Runs to completion once started: the concurrency group no longer cancels
# in-flight ticks (see the concurrency block), because a smoke killed mid-run
# is not harmlessly idempotent — the next tick evaluates NEWER inputs, a
# fresher beta shadows the nominated candidate, and the qualified cut never
# gets its answer. The job still holds no state a crash could corrupt: it
# downloads to the runner's scratch, mounts through the helper (which
# detaches on SIGTERM), and its only effect is the final dispatch.
smoke-fast-tier-candidate:
name: Smoke the fast-tier candidate's DMG
needs: evaluate
if: needs.evaluate.outputs.fast_tier_candidate != ''
# Apple Silicon: the DMG is a Universal build, but launching it needs macOS.
runs-on: macos-26
timeout-minutes: 30
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Set up Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: "24"
- name: Install dependencies
run: |
set -euo pipefail
corepack enable
pnpm install --frozen-lockfile
- name: Download the candidate's DMG
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
CANDIDATE: ${{ needs.evaluate.outputs.fast_tier_candidate }}
run: |
set -euo pipefail
mkdir -p "${RUNNER_TEMP}/fast-tier-dmg"
gh release download "$CANDIDATE" --pattern '*.dmg' --dir "${RUNNER_TEMP}/fast-tier-dmg"
- name: Smoke the DMG
id: smoke
env:
CANDIDATE: ${{ needs.evaluate.outputs.fast_tier_candidate }}
run: |
set -uo pipefail
DMG="$(find "${RUNNER_TEMP}/fast-tier-dmg" -maxdepth 1 -name '*.dmg' | head -1)"
if [[ -z "$DMG" ]]; then
echo "verdict=error" >> "$GITHUB_OUTPUT"
echo "::warning::No .dmg downloaded for ${CANDIDATE}; refusing the fast tier (the 24h path is unaffected)."
exit 0
fi
# The driver exits non-zero on fail and error. `|| true` is correct
# here and only here: this job must never fail, and the verdict it
# wrote to GITHUB_OUTPUT is the signal the dispatch below reads.
node .github/scripts/smoke-packaged-dmg.mjs "$DMG" || true
- name: Dispatch promote-stable for the smoke-proven candidate
if: steps.smoke.outputs.verdict == 'pass'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
CANDIDATE: ${{ needs.evaluate.outputs.fast_tier_candidate }}
run: |
set -euo pipefail
# Same in-flight guard the 24h path uses — don't double-fire while a
# promotion is already running.
active=$(gh run list --workflow=promote-stable.yml --json status \
--jq '[.[] | select(.status=="in_progress" or .status=="queued")] | length')
if [[ "$active" -gt 0 ]]; then
echo "::notice::A promote-stable run is already in_progress/queued (${active}); skipping the fast-tier dispatch."
exit 0
fi
SELF_URL="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}"
gh workflow run promote-stable.yml -f beta_tag="$CANDIDATE" -f dispatched_by="$SELF_URL"
echo "::notice::Fast tier: dispatched promote-stable for ${CANDIDATE} after its DMG smoked clean."
- name: Record a fast-tier refusal
if: steps.smoke.outputs.verdict != 'pass'
env:
CANDIDATE: ${{ needs.evaluate.outputs.fast_tier_candidate }}
VERDICT: ${{ steps.smoke.outputs.verdict }}
SLACK_RELEASES_WEBHOOK_URL: ${{ secrets.SLACK_RELEASES_WEBHOOK_URL }}
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
run: |
set -uo pipefail
echo "::notice::Fast tier refused for ${CANDIDATE} (verdict=${VERDICT:-unknown}). The 24h tier is unaffected; this beta promotes normally once it has soaked."
# Per-refusal page. A fast-tier tick can fire at 03:00 on a Sunday;
# without this the only trace of the gate biting is a job notice
# nobody is watching, which makes the gate decorative. Best-effort:
# a failed POST is a warning, never a job failure — the refusal
# itself already protected the release.
WEBHOOK_URL="${SLACK_RELEASES_WEBHOOK_URL:-${SLACK_WEBHOOK_URL:-}}"
if [[ -z "$WEBHOOK_URL" ]]; then
echo "::notice::No Slack webhook secret set — refusal page skipped."
exit 0
fi
RUN_URL="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}"
TEXT="⛔ Fast-tier DMG smoke REFUSED for ${CANDIDATE} (verdict=${VERDICT:-unknown}). The beta stays on the 24h soak; investigate the smoke before it promotes. ${RUN_URL}"
payload="$(jq -nc --arg t "$TEXT" '{text: $t}')"
if curl -sS --fail -X POST -H 'Content-type: application/json' --data "$payload" "$WEBHOOK_URL"; then
echo "Posted the fast-tier refusal page."
else
echo "::warning::Fast-tier refusal page failed to POST — the refusal itself still holds."
fi
# Aggregate silent-failure alarm.
#
# The per-cut alert in desktop-release.yml covers a gate that BITES. This
# covers the failure mode nobody notices: a gate that is quietly
# always-refusing, or a fast tier that has gone inert. Both look exactly like
# a calm week.
#
# Silent by default and by design. With the fast tier unarmed the smoke job
# never runs, the derived history is empty, and the evaluator reports no
# alarm — an intentionally disarmed tier is not a broken one, and an alarm
# that fired continuously while the tier was off would train responders to
# ignore it.
#
# Schedule-only: the alarm is about a trend, so evaluating it on every push
# would add noise without adding signal. Stateless — it derives history from
# this workflow's own run records and writes nothing back.
aggregate-smoke-alarm:
name: Aggregate DMG-smoke alarm
if: github.event_name == 'schedule'
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Set up Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: "24"
- name: Evaluate the aggregate alarm
id: alarm
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Both alarm conditions are statements about an ARMED tier. Passing
# the switch in lets the evaluator stay silent while it is off,
# instead of inferring "armed" from history that a disarmed tier
# still produces.
FAST_TIER_ARMED: ${{ env.FAST_TIER_ARMED }}
run: node .github/scripts/evaluate-smoke-alarm.mjs
# Routed to the same channel as the per-cut smoke alert, not to a
# workflow annotation alone — an annotation on a scheduled job nobody
# opens is indistinguishable from silence. Slack only, for the reason the
# per-cut alert is: Discord carries shipped releases, not gate health.
- name: Page the release channel
if: steps.alarm.outputs.alarm == 'true'
env:
SLACK_RELEASES_WEBHOOK_URL: ${{ secrets.SLACK_RELEASES_WEBHOOK_URL }}
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
REASONS: ${{ steps.alarm.outputs.reasons }}
run: |
set -uo pipefail
RUN_URL="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}"
TEXT="🚨 DMG SMOKE GATE ALARM — ${REASONS}. Investigate before trusting the fast tier. ${RUN_URL}"
# `text` only: `content` was Discord's field, and Slack is now the
# sole recipient.
payload="$(jq -nc --arg t "$TEXT" '{text: $t}')"
post() {
local webhook="$1" label="$2"
if [[ -z "$webhook" ]]; then
echo "::notice::${label} webhook not set — skipping the ${label} aggregate alarm."
return 0
fi
if curl -sS --fail -X POST -H 'Content-type: application/json' --data "$payload" "$webhook"; then
echo "Posted the ${label} aggregate alarm."
else
echo "::warning::${label} aggregate alarm failed to POST — the alarm condition still holds."
fi
}
post "${SLACK_RELEASES_WEBHOOK_URL:-${SLACK_WEBHOOK_URL:-}}" Slack
echo "::warning::DMG smoke gate alarm: ${REASONS}"