main reset: post-stable v0.77.1 (#4778) #5693
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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}" |