mirror of
https://github.com/nexu-io/open-design.git
synced 2026-10-27 09:51:12 +01:00
Reverses the "withhold the card until the first artifact publishes" rule. The card is now posted on the watcher's first poll, while every platform is still queued, and every later state edits that same message. The reason the card was withheld — a "构建中…" message left standing forever when a build dies — has since been answered by the machinery that grew around the card: a dead lane renders its own terminal state, a release where nothing shipped turns the card red, the watcher's 140-minute budget stamps the card instead of abandoning it, and a watcher that never delivered draws the fallback bot. Silence for the whole build window bought nothing. `shouldPostFirstCard` conflated two predicates. One half was already `anyPackagePublished`; the other is now `allPlatformsFailed`, and it moves from gating the post to being the card's red verdict — the header's old "some platform reported and none shipped" proxy is only equivalent while the card does not exist before total failure, and with the card posted early it reads 全部平台构建失败 the moment one lane fails, or red at t=0 because `build_linux` is skipped on every run. Each platform row now carries its build time (`用时 11m42s` once finished, `已用 18m` while running) and the footer carries the whole round's clock, both read from the `started_at` / `completed_at` already in the polled job list plus the origin run's `created_at`. Live numbers are quantized to the minute so a 30-second poll does not PATCH the card on every cycle.
293 lines
14 KiB
YAML
293 lines
14 KiB
YAML
name: release-prerelease-card
|
|
|
|
# The progressive Feishu card for one prerelease.
|
|
#
|
|
# The old card was a custom-bot webhook post at the very end of the pipeline:
|
|
# one immutable message, delivered only once everything had finished, and blind
|
|
# to whether the packages had actually shipped. This replaces it with a Feishu
|
|
# *application* bot, which can PATCH a message it sent — so one card is created
|
|
# on this job's first poll, while every platform is still queued, and is edited
|
|
# in place as the platforms, the code tests, and the packaged smoke report in.
|
|
# Each platform row carries its own build time and the footer carries the whole
|
|
# round's, both read from the same jobs API this job already polls.
|
|
#
|
|
# Exactly one job writes that card, on purpose. A PATCH replaces the whole card,
|
|
# so three build jobs updating "their own" row would each erase the other two.
|
|
# This job therefore owns the message and learns the state by polling the jobs
|
|
# API of the three runs involved (build/publish, tests, smoke).
|
|
#
|
|
# It is dispatched by release-prerelease.yml's `dispatch_validation` job at the
|
|
# moment the build commit is resolved — before any package exists — and it holds
|
|
# a concurrency group of its own, so its long watch cannot delay a build.
|
|
#
|
|
# Being the ONLY prerelease notification makes every way this workflow can break
|
|
# a silent one: the pipeline stays green, the packages ship, and the channel
|
|
# hears nothing. `fallback_notice` below is the answer — it fires only when the
|
|
# card did not reach the group, and it delivers through the custom-bot webhook,
|
|
# a different bot with a different secret, so an expired application credential
|
|
# cannot take the alert down with the card.
|
|
|
|
on:
|
|
workflow_dispatch:
|
|
inputs:
|
|
version:
|
|
description: "Prerelease version this card reports, e.g. 0.21.1-prerelease.3."
|
|
required: true
|
|
type: string
|
|
commit:
|
|
description: "Commit being built. Also the changelog's upper bound."
|
|
required: true
|
|
type: string
|
|
branch:
|
|
description: "Branch that was built."
|
|
required: false
|
|
type: string
|
|
default: ""
|
|
previous_commit:
|
|
description: "Commit of the previously published prerelease; the changelog's lower bound. Empty on a cold start, which the card reports as 首个 Prerelease 包."
|
|
required: false
|
|
type: string
|
|
default: ""
|
|
origin_run_id:
|
|
description: "Run id of the release-prerelease pipeline this card is watching."
|
|
required: true
|
|
type: string
|
|
expect_tests:
|
|
description: "Whether release-prerelease-tests.yml was dispatched for this build. When false the card renders no test rows instead of waiting for results that will never arrive."
|
|
required: false
|
|
type: boolean
|
|
default: true
|
|
expect_smoke:
|
|
description: "Whether release-prerelease-smoke.yml will be dispatched once the packages publish."
|
|
required: false
|
|
type: boolean
|
|
default: true
|
|
|
|
run-name: prerelease card · ${{ inputs.version }} · origin-run ${{ inputs.origin_run_id }}
|
|
|
|
permissions:
|
|
# Reads the job state of the build, test, and smoke runs. It writes nothing to
|
|
# the repository — the only side effect is a Feishu message.
|
|
actions: read
|
|
contents: read
|
|
|
|
concurrency:
|
|
group: release-prerelease-card-${{ inputs.origin_run_id }}
|
|
cancel-in-progress: false
|
|
|
|
jobs:
|
|
card:
|
|
name: Track prerelease on one Feishu card
|
|
if: github.repository == 'nexu-io/open-design'
|
|
runs-on: ubuntu-latest
|
|
# Long enough to outlive the slowest lane it watches (mac Intel build ~33
|
|
# min, P0 Functional E2E, then the packaged smoke after publish), with the
|
|
# watcher's own budget set below it so the card gets a final "timed out"
|
|
# update instead of being killed mid-poll.
|
|
timeout-minutes: 150
|
|
outputs:
|
|
# "The card's FINAL state is in the chat", written by the watcher itself.
|
|
# Deliberately not derivable from this job's result: the watcher catches
|
|
# every Feishu error on purpose (one hiccup must not end a two-hour watch)
|
|
# and exits 0 regardless, so an application bot whose credentials expired
|
|
# produces a green job and an empty channel. The fallback job below keys
|
|
# on this, not on `result`.
|
|
delivered: ${{ steps.track.outputs.card_delivered }}
|
|
steps:
|
|
- name: Checkout
|
|
uses: actions/checkout@v6.0.2
|
|
with:
|
|
# Deliberately the ref this workflow was dispatched on, NOT the built
|
|
# commit: the watcher is infrastructure, and pinning it to the build's
|
|
# commit would run whatever notifier that branch happened to carry.
|
|
fetch-depth: 1
|
|
- name: Setup Node.js
|
|
uses: actions/setup-node@v6
|
|
with:
|
|
node-version: 24
|
|
|
|
- name: Compute changelog since last prerelease
|
|
id: changelog
|
|
env:
|
|
GH_TOKEN: ${{ github.token }}
|
|
PREV: ${{ inputs.previous_commit }}
|
|
CUR: ${{ inputs.commit }}
|
|
run: |
|
|
set -euo pipefail
|
|
file="$RUNNER_TEMP/changelog.txt"
|
|
: > "$file"
|
|
# Set the output before any early return, so every path below still
|
|
# hands the card a real (possibly empty) file: no baseline, or an
|
|
# unreachable commit, means an empty changelog, never a failed card.
|
|
echo "file=$file" >> "$GITHUB_OUTPUT"
|
|
|
|
if [ -z "$PREV" ] || [ -z "$CUR" ]; then
|
|
echo "no comparable range (PREV='${PREV}' CUR='${CUR}'); changelog stays empty"
|
|
exit 0
|
|
fi
|
|
|
|
raw="$RUNNER_TEMP/changelog-compare.txt"
|
|
meta="$RUNNER_TEMP/changelog-compare-meta.txt"
|
|
: > "$raw"
|
|
: > "$meta"
|
|
|
|
# Reproduces `git log --no-merges --pretty=format:'%s (%h)' PREV..CUR`
|
|
# against the compare API, so the card needs no git history at all:
|
|
# * `select((.parents | length) < 2)` is --no-merges.
|
|
# * %s is the subject — the first paragraph folded onto one line —
|
|
# so split on the blank line, then collapse any interior newline.
|
|
# * %h abbreviated to 10 characters, which is what git's automatic
|
|
# abbreviation resolves to on this repository's object count.
|
|
# `--paginate` walks the compare endpoint's Link headers; both the page
|
|
# order and the order within a page are oldest-first, so a single `tac`
|
|
# at the end restores git log's newest-first order (which matters: the
|
|
# card renders only the first 30 lines).
|
|
subject='(.commit.message | split("\n\n")[0] | gsub("[\r\n]+"; " ") | sub("^\\s+"; "") | sub("\\s+$"; ""))'
|
|
if ! gh api --paginate \
|
|
"repos/${GITHUB_REPOSITORY}/compare/${PREV}...${CUR}?per_page=100" \
|
|
--jq ".commits[] | select((.parents | length) < 2) | \"\\(${subject}) (\\(.sha[0:10]))\"" \
|
|
> "$raw"; then
|
|
echo "::warning::compare ${PREV}...${CUR} failed (commit unreachable, history rewritten, or API error); the card will carry no changelog"
|
|
exit 0
|
|
fi
|
|
|
|
tac "$raw" > "$file"
|
|
|
|
# The compare endpoint caps at 250 commits. Pagination still returns
|
|
# the newest page last, so the newest 30 lines the card renders are
|
|
# exact either way; only the "共 N 条" total would under-report.
|
|
if gh api "repos/${GITHUB_REPOSITORY}/compare/${PREV}...${CUR}?per_page=1" \
|
|
--jq '.total_commits' > "$meta" 2>/dev/null; then
|
|
total="$(tr -d '[:space:]' < "$meta")"
|
|
collected="$(wc -l < "$raw" | tr -d ' ')"
|
|
echo "compare ${PREV}...${CUR}: total_commits=${total} non-merge lines collected=${collected}"
|
|
if printf '%s' "$total" | grep -Eq '^[0-9]+$' && [ "$total" -gt 250 ]; then
|
|
echo "::warning::compare returned ${total} commits, above the endpoint's 250 cap; the card lists the newest commits correctly but its total under-reports"
|
|
fi
|
|
fi
|
|
|
|
- name: Track the prerelease on one card
|
|
id: track
|
|
env:
|
|
BRANCH: ${{ inputs.branch }}
|
|
CARD_TIMEOUT_MS: "8400000"
|
|
CHANGELOG_FILE: ${{ steps.changelog.outputs.file }}
|
|
CHANNEL_LABEL: Prerelease
|
|
COMMIT: ${{ inputs.commit }}
|
|
EXPECT_SMOKE: ${{ inputs.expect_smoke }}
|
|
EXPECT_TESTS: ${{ inputs.expect_tests }}
|
|
FEISHU_APP_ID: ${{ secrets.FEISHU_APP_ID }}
|
|
FEISHU_APP_SECRET: ${{ secrets.FEISHU_APP_SECRET }}
|
|
FEISHU_RELEASE_CHAT_ID: ${{ secrets.FEISHU_RELEASE_CHAT_ID }}
|
|
GH_TOKEN: ${{ github.token }}
|
|
ORIGIN_RUN_ID: ${{ inputs.origin_run_id }}
|
|
PREVIOUS_COMMIT: ${{ inputs.previous_commit }}
|
|
RELEASE_CHANNEL: prerelease
|
|
RELEASE_PUBLIC_ORIGIN: ${{ vars.CLOUDFLARE_R2_RELEASES_PUBLIC_ORIGIN }}
|
|
VERSION: ${{ inputs.version }}
|
|
run: node --experimental-strip-types tools/release/src/notifications/prerelease-progress-card.ts
|
|
|
|
fallback_notice:
|
|
name: Alert when the card never reached the group
|
|
needs: card
|
|
# `always() && !cancelled()` is load-bearing, not decoration. GitHub applies
|
|
# an implicit `success()` to a job with `needs`, which would skip this one in
|
|
# exactly the case it exists for — a card job that died — without ever
|
|
# evaluating the conditions below. (The same trap skipped
|
|
# release-prerelease.yml's `dispatch_smoke` for N runs.) `card` is this
|
|
# workflow's only other job and sits downstream of nothing, so that break is
|
|
# the whole reachability story here: the permanently-skipped `build_linux`
|
|
# chain that poisons release-prerelease.yml's graph does not exist in this
|
|
# two-job workflow.
|
|
#
|
|
# The last clause is the "never fire on a healthy release" rule. A green
|
|
# watcher that ALSO reports a delivered card is the normal outcome and must
|
|
# skip this job; a green watcher without that report is the silent failure.
|
|
# Reading `needs.card.result` alone would miss every credential failure.
|
|
#
|
|
# The repository guard mirrors `card`'s own: a fork has neither bot's
|
|
# credentials, and must not be told its card lane is broken.
|
|
if: >-
|
|
${{
|
|
always() && !cancelled() &&
|
|
github.repository == 'nexu-io/open-design' &&
|
|
!(needs.card.result == 'success' && needs.card.outputs.delivered == 'true')
|
|
}}
|
|
runs-on: ubuntu-latest
|
|
timeout-minutes: 10
|
|
env:
|
|
# The OTHER bot, on purpose. The card posts through the Feishu application
|
|
# bot (FEISHU_APP_ID / FEISHU_APP_SECRET / FEISHU_RELEASE_CHAT_ID); this
|
|
# posts through the custom-bot webhook that the retired one-shot card used.
|
|
# A fallback sharing the primary's credentials would cover nothing —
|
|
# "the application bot expired or was removed from the chat" is one of the
|
|
# ways the card goes silent, and it must not take the alert down too.
|
|
FEISHU_SIGN_SECRET: ${{ secrets.FEISHU_RELEASE_SIGN_SECRET }}
|
|
FEISHU_WEBHOOK: ${{ secrets.FEISHU_RELEASE_WEBHOOK }}
|
|
steps:
|
|
- name: Checkout notifier
|
|
uses: actions/checkout@v6.0.2
|
|
with:
|
|
# Both scripts below import nothing but node builtins, so this job
|
|
# needs no dependency install — which is the point: the last-resort
|
|
# path should have as little between it and the POST as possible.
|
|
fetch-depth: 1
|
|
- name: Setup Node.js
|
|
uses: actions/setup-node@v6
|
|
with:
|
|
node-version: 24
|
|
|
|
- name: Compose the fallback notice
|
|
id: notice
|
|
env:
|
|
BRANCH: ${{ inputs.branch }}
|
|
CARD_DELIVERED: ${{ needs.card.outputs.delivered }}
|
|
CARD_JOB_RESULT: ${{ needs.card.result }}
|
|
CARD_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
|
CHANNEL_LABEL: Prerelease
|
|
COMMIT: ${{ inputs.commit }}
|
|
GH_TOKEN: ${{ github.token }}
|
|
LANE_SUMMARY: "card job=${{ needs.card.result }} · delivered=${{ needs.card.outputs.delivered || 'false' }}"
|
|
# The card is dispatched before any package exists, so unlike
|
|
# release-prerelease.yml this caller holds no version_metadata_url.
|
|
# It reconstructs the same R2 object and HEADs it, which is the same
|
|
# authority by a different route, and asks the origin run whether the
|
|
# pipeline is still going — that is what tells "never shipped" apart
|
|
# from "has not shipped yet" when the card dies early.
|
|
ORIGIN_RUN_ID: ${{ inputs.origin_run_id }}
|
|
ORIGIN_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ inputs.origin_run_id }}
|
|
RELEASE_CHANNEL: prerelease
|
|
RELEASE_PUBLIC_ORIGIN: ${{ vars.CLOUDFLARE_R2_RELEASES_PUBLIC_ORIGIN }}
|
|
STAGE: watch
|
|
VERSION: ${{ inputs.version }}
|
|
run: node --experimental-strip-types tools/release/src/notifications/prerelease-fallback-notice.ts
|
|
|
|
- name: Fail loudly when the fallback bot is itself unconfigured
|
|
# Both notification paths being down at once is an incident of its own,
|
|
# and a red job in the Actions list is the only channel left to say so.
|
|
# `always()` because the whole point is to speak after something
|
|
# broke — without it GitHub's implicit success() skips this step in
|
|
# every run where an earlier step already failed. The outcome check
|
|
# is the other half: never act on a composer that did not finish.
|
|
if: >-
|
|
${{
|
|
always() && steps.notice.outcome == 'success' &&
|
|
steps.notice.outputs.alert == 'true' && env.FEISHU_WEBHOOK == ''
|
|
}}
|
|
run: |
|
|
echo "::error::the prerelease card lane went silent and FEISHU_RELEASE_WEBHOOK is unset, so no fallback notice could be delivered"
|
|
exit 1
|
|
|
|
- name: Post the fallback notice
|
|
if: >-
|
|
${{
|
|
always() && steps.notice.outcome == 'success' &&
|
|
steps.notice.outputs.alert == 'true' && env.FEISHU_WEBHOOK != ''
|
|
}}
|
|
env:
|
|
NOTICE_BODY: ${{ steps.notice.outputs.body }}
|
|
NOTICE_TEMPLATE: ${{ steps.notice.outputs.template }}
|
|
NOTICE_TITLE: ${{ steps.notice.outputs.title }}
|
|
RUN_URL: ${{ steps.notice.outputs.run_url }}
|
|
run: node --experimental-strip-types tools/release/src/notifications/feishu-notice.ts
|