Skip to content

Commit 54c37fb

Browse files
claude[bot]claude
andauthored
ci(devx): label-gated opt-in pre-merge pack smoke (#14266)
Add a `pull_request` trigger that runs the existing pack-install smoke on a PR's merge preview while the PR carries `needs:pack-smoke`. The pack smoke previously ran only on `workflow_run` after a Release run, so its verdict landed as a commit status on the changesets release branch head -- a surface no PR author reads. A breaking auth/audience change therefore merged with zero packed-install coverage on its own PR, and the smoke then sat red on the release candidate for ~7 days. This adds the trigger only. The driver, its probes and every assertion in scripts/publish-smoke.sh are untouched: re-pinning those to the declared first-run contract was a separate deliverable that has already landed. Opt-in by ruling -- an unlabelled PR pays no new cost. The guard's second limb keeps an unrelated label add (size/*, ci/cd) from restarting a 45-minute smoke, and the concurrency group sits at job level so a run that skips can never cancel a run that is really smoking. Claude-Session: https://claude.ai/code/session_01WLJQhde67SeTccsmnBVarV Co-authored-by: Claude <noreply@anthropic.com>
1 parent 9e5cd71 commit 54c37fb

3 files changed

Lines changed: 185 additions & 0 deletions

File tree

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
# Opt-in pre-merge pack smoke (#14214, derived from the #14000 ruling).
2+
#
3+
# ## The gap this closes
4+
#
5+
# `publish-smoke.yml` owns the pack smoke, and it runs on `workflow_run` AFTER a
6+
# Release run — it resolves the changesets release branch and reports its verdict
7+
# as a commit status on that branch head. That is the right place for a release
8+
# verdict and the wrong place for a PR author: #11767 (audience default flipped
9+
# to `invite_only`, a breaking auth change) merged with ZERO packed-install
10+
# coverage on its own PR, and the smoke then sat red on the release candidate for
11+
# ~7 days because the only surface carrying the verdict was one no PR author
12+
# looks at (measured in #14000).
13+
#
14+
# This workflow adds the missing TRIGGER — and only the trigger. The driver, the
15+
# probes and every assertion stay exactly as `scripts/publish-smoke.sh` defines
16+
# them; re-pinning those to the declared first-run contract was #14000's own
17+
# deliverable (PR #14255) and is deliberately untouched here. What is new is
18+
# WHEN the smoke runs and WHERE its verdict lands: on the pull request, before
19+
# the merge, as an ordinary check run the author already reads.
20+
#
21+
# ## Opt-in, by ruling — and what that buys and costs
22+
#
23+
# Maintainer ruling 2026-09-01 (director batch #23, 「同意」) approved the
24+
# LABEL-GATED OPT-IN shape: the cost stays in opt-in. A full build + pack +
25+
# clean install is ~45 minutes, so putting it in the default inner loop would
26+
# tax every PR in the repo to cover the handful that can break a fresh install.
27+
#
28+
# ⛔ Therefore this workflow must never grow a `paths:` filter that runs it by
29+
# default, and must never be added to `.github/labeler.yml`. Auto-applying the
30+
# label from changed paths would be a DIFFERENT design (auto-detect), not the
31+
# one that was ruled, and it would reintroduce the default-inner-loop cost the
32+
# ruling removed.
33+
#
34+
# The residual risk is stated rather than hidden: self-declaration only covers
35+
# the author who RECOGNISES their change as breaking. An author who does not
36+
# realise they widened the unauthenticated surface still gets no pre-merge pack
37+
# coverage — the release-time leg in `publish-smoke.yml` remains the backstop for
38+
# that case. Closing that gap would require a detection rule, which is a
39+
# different card and a different ruling.
40+
#
41+
# ## When to apply the label
42+
#
43+
# Apply `needs:pack-smoke` to your own PR when it changes the auth/audience
44+
# DEFAULTS or the accept/reject behaviour of the unauthenticated surface. The
45+
# same criterion is written where PR authors meet it, in CONTRIBUTING.md.
46+
#
47+
# ## Wiring decisions
48+
#
49+
# `types:` restates GitHub's three defaults alongside `labeled`, because naming
50+
# `types:` REPLACES the default set rather than extending it (#8304) — dropping
51+
# one produces no run on that activity, which is an absence rather than a skip.
52+
# `opened` is in the list so a PR created via the API already carrying the label
53+
# is smoked; `synchronize` is the load-bearing one, because the thing under test
54+
# is the MERGE PREVIEW and every push changes it.
55+
#
56+
# The guard has two limbs and both are needed:
57+
#
58+
# 1. the PR must CURRENTLY carry the label — this is what makes an unlabelled
59+
# PR cost nothing;
60+
# 2. on a `labeled` event the label just added must be OURS. Without this limb
61+
# every unrelated label the size labeler adds (`size/l`, `ci/cd`, …) to an
62+
# already-labelled PR would start another 45-minute smoke of a tree that
63+
# has not changed.
64+
#
65+
# `concurrency` sits at JOB level, not workflow level, and that is deliberate.
66+
# At workflow level the group is taken by every run this workflow starts,
67+
# including the runs whose job then skips — so an unrelated `labeled` event
68+
# would CANCEL a smoke that was still running and then skip, leaving the PR with
69+
# no verdict at all. That is the #14000 silence shape (a missing answer reading
70+
# as a green one) rebuilt inside its own fix. A skipped job never enters a
71+
# job-level group, so only a real smoke can supersede a real smoke.
72+
#
73+
# ⛔ No `merge_group:` trigger, and this is not the oversight it resembles. A
74+
# `merge_group` event carries no `pull_request` context, so neither limb of the
75+
# guard above can be evaluated there; and the ruling puts this cost pre-merge and
76+
# opt-in, not in the queue. This job is correspondingly NOT a required context
77+
# (`scripts/check-required-contexts.mjs` holds that registry) — it is advisory by
78+
# design, and a PR that never opts in simply never produces it.
79+
80+
name: Pack Smoke (opt-in)
81+
82+
on:
83+
pull_request:
84+
branches:
85+
- main
86+
types: [opened, synchronize, reopened, labeled]
87+
88+
permissions:
89+
contents: read
90+
91+
jobs:
92+
pack-smoke:
93+
# ~45 minutes of build + pack + clean install. Opt-in only — see the header.
94+
name: Packed-tarball smoke (opt-in)
95+
if: >-
96+
contains(github.event.pull_request.labels.*.name, 'needs:pack-smoke')
97+
&& (github.event.action != 'labeled' || github.event.label.name == 'needs:pack-smoke')
98+
runs-on: ubuntu-latest
99+
timeout-minutes: 45
100+
concurrency:
101+
group: pack-smoke-optin-${{ github.event.pull_request.number }}
102+
cancel-in-progress: true
103+
steps:
104+
# No `ref:` — on `pull_request` the default checkout is `refs/pull/N/merge`,
105+
# the MERGE PREVIEW, which is the tree the ruling names: what `main` will
106+
# actually contain, not what the branch contains in isolation. The driver
107+
# packs from the checkout root (`REPO_ROOT` in scripts/publish-smoke.sh),
108+
# so the preview is what gets packed, installed and probed.
109+
- name: Checkout the merge preview
110+
uses: actions/checkout@v7
111+
112+
- name: Setup Node.js
113+
uses: actions/setup-node@v7
114+
with:
115+
node-version: '22'
116+
117+
- name: Setup pnpm
118+
uses: ./.github/actions/setup-pnpm
119+
120+
- name: Get pnpm store directory
121+
shell: bash
122+
run: echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
123+
124+
- name: Setup pnpm cache
125+
uses: actions/cache@v6
126+
with:
127+
path: ${{ env.STORE_PATH }}
128+
key: ${{ runner.os }}-pnpm-store-v3-${{ hashFiles('**/pnpm-lock.yaml') }}
129+
restore-keys: |
130+
${{ runner.os }}-pnpm-store-v3-
131+
132+
- name: Setup turbo cache
133+
uses: actions/cache@v6
134+
with:
135+
path: .turbo/cache
136+
key: ${{ runner.os }}-turbo-${{ github.job }}-${{ github.sha }}
137+
restore-keys: |
138+
${{ runner.os }}-turbo-${{ github.job }}-
139+
${{ runner.os }}-turbo-
140+
141+
- name: Install dependencies
142+
run: pnpm install --frozen-lockfile
143+
144+
# The driver's own prerequisite, asserted by the script itself: pack mode
145+
# fails fast unless packages/cli/dist and the create-objectstack bin exist.
146+
- name: Build
147+
run: pnpm run build
148+
149+
# SMOKE_MODE defaults to `pack`, so this is the same invocation
150+
# publish-smoke.yml's pack-smoke job makes. ⛔ Do not add flags or env here
151+
# to make a red go away: the assertions are #14000's deliverable and a
152+
# refusal this script reports is a refusal a user would get.
153+
- name: Publish smoke (packed tarballs)
154+
run: bash scripts/publish-smoke.sh

CONTRIBUTING.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -363,6 +363,9 @@ Provide both English and Chinese versions:
363363
- [ ] Naming conventions are followed
364364
- [ ] JSDoc comments are complete
365365
- [ ] No unrelated changes included
366+
- [ ] If the PR changes the auth/audience **defaults** or the **accept/reject behaviour** of
367+
the unauthenticated surface, label it `needs:pack-smoke` — that runs the packed-install
368+
smoke on the merge preview before you merge, instead of finding out at release time.
366369

367370
### PR Checklist
368371

scripts/pm/ensure-pm-labels.sh

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -282,6 +282,34 @@ done
282282
# a script would be issuing the review verdict, which is 自查放行.
283283
gh label create needs:contract-review -R objectstack-ai/objectstack -c d93f0b -d "Clause-② enqueue gate: dispatched below contract-review tier — blocked until the review clears it" 2>/dev/null || true
284284

285+
# needs:pack-smoke — the opt-in pre-merge pack smoke (#14214, derived from the
286+
# #14000 ruling; maintainer-approved 2026-09-01, director batch #23, 「同意」).
287+
# Its named consumer is a GATE rather than a query: the job guard in
288+
# `.github/workflows/pack-smoke-optin.yml`, which runs the packed-install smoke
289+
# on the PR's merge preview only while this label is on the PR.
290+
#
291+
# ⚠️ It is the first label in this file that is NOT part of the PM state
292+
# machine, and the difference matters to anyone extending it. Every pm:* row
293+
# above is a state a PM seat writes; this one is written by the PR AUTHOR about
294+
# their own diff — the ruling's word is 自声明 — and it names a property of the
295+
# CHANGE, not a position on the board. So it is one-of with nothing, it blocks
296+
# no dispatch, and no sweep in scripts/pm/ reads it. It is declared here anyway
297+
# because this file is the repo's only declared home for a label OBJECT, and an
298+
# undeclared label is the grey / empty-description drift the header describes:
299+
# auto-created by its first application and unrepairable by any rerun.
300+
#
301+
# ⛔ Never auto-applied. Nothing in .github/labeler.yml may grow a rule for it
302+
# (the workflow header argues why: path-derived application is a different
303+
# design from the one that was ruled, and it puts the ~45-minute cost back into
304+
# the default inner loop the ruling kept it out of). Like every other row here,
305+
# this file only ensures the OBJECT — it never hangs the label on a PR.
306+
#
307+
# Main repo only, for the ordinary reason: the workflow that consumes it lives
308+
# here. Colour 006b75 is deliberately outside every family above — this is not a
309+
# lane, not a state, and not a routing seam, and borrowing one of their colours
310+
# would assert a kinship the paragraph above spends its length denying.
311+
gh label create needs:pack-smoke -R objectstack-ai/objectstack -c 006b75 -d "Opt-in pre-merge pack smoke: self-declared breaking auth/audience change (see CONTRIBUTING.md)" 2>/dev/null || true
312+
285313
# Routing labels exist only on the main backlog repo, and mark SEAM cards only
286314
# (file-at-destination ruling: pure sibling-repo fixes live in the target repo).
287315
#

0 commit comments

Comments
 (0)