Skip to content

Commit 671df26

Browse files
Move the three untested inline workflow step bodies into scripts/ci with self-tests (#26386)
<!-- claude-projects-attribution: {"github_login":"os-steve"} --> _Requested by **Steve** · [project thread](https://claude.ai/code/project/chan_01FdL1XeUyvVNxLreh3F4Qi9?thread=cmsg_01FdL1XeUyvVNxLreh3F4Qi95YovdFSHTxz132KjPG4TAa)_ Part of #26086 Clause-②: no Metadata-protocol: none ## 维护者速读 - 改了什么:三个只在「没有 PR 能触发的事件」上跑的工作流步骤(定时全量红卡、nightly 红卡、看板备份上传 R2),把内联主体搬进 `scripts/ci/`,每个都配了离线自测,并接进 `lint-ci-engine` 道。 - 为什么改:这三段主体原来没有任何测试能碰到,第一次执行就是线上真跑。卡上其余 17 个超 80 行的主体,大多已经有门禁在读它的原样字节;release.yml 的 3 步有信任边界,不能搬。逐个原因见下文 Acceptance notes。 - 风险与回滚:行为不变。github-script 步骤保留原来的 token、`retries:` 和权限,只是改成 import 模块后薄调用;bash 主体逐字节不变。回滚就是 revert 这个 PR。 - 先看:`scripts/ci/scheduled-full-run-card.mjs`(调用和回调接线)、`scripts/ci/nightly-tiers-card.mjs`、`.github/workflows/lint.yml` 新增的那一步。 - 两分钟验证:`node scripts/ci/scheduled-full-run-card.mjs --self-test && node scripts/ci/nightly-tiers-card.mjs --self-test && bash scripts/ci/board-snapshot-r2-upload.selftest.sh`,三行 ✓,共 7 + 7 + 5 个用例。 - 需要你做的:这是 Tier H(改 `.github/workflows/**`),需要 GOVERNED_APPROVERS(os-zhuang 或 hotlong)批准;PR 1 的范围(只搬没有测试台的部分,还是照卡全搬)在项目线程里等 Steve 裁决。 ## What changes Three inline step bodies that no test reached move into `scripts/ci/`, each with an offline self-test: | Workflow step | Lines moved | New home | Self-test | |---|---|---|---| | `scheduled-full-run-card.yml` · File or refresh the card (github-script) | 121 | `scripts/ci/scheduled-full-run-card.mjs` · `fileCardStep` | 7 cases | | `test-nightly-tiers.yml` · File or refresh the card (github-script) | 158 | `scripts/ci/nightly-tiers-card.mjs` · `fileNightlyCard` | 7 cases | | `board-snapshot.yml` · Upload the archive to R2 (run) | 125 | `scripts/ci/board-snapshot-r2-upload.sh` | `scripts/ci/board-snapshot-r2-upload.selftest.sh`, 5 cases / 27 checks | - **github-script bodies keep their step.** The step now does `await import(GITHUB_WORKSPACE + '/scripts/ci/X.mjs')` and calls the exported function with the same `github` / `context` / `core`. That keeps the token, the `retries:` / `retry-exempt-status-codes:` policy and the job's `permissions:` exactly as they were. It follows `scheduled-full-run-card.yml`'s existing import of `scripts/ci/scheduled-full-run.mjs`. This is a deliberate deviation from the card's literal "the YAML step becomes `node scripts/ci/X.mjs`": a bare `node` step would need its own octokit, token plumbing and retry policy, which is a behaviour change. Both jobs already check out the repository. - **The bash body moves byte for byte.** `scripts/ci/board-snapshot-r2-upload.sh` is the inline block plus a header and the `set -e` that GitHub's default `bash -e {0}` shell supplied. `cmp` against the YAML-parsed block is identical. The step keeps its `env:` and runs `bash scripts/ci/board-snapshot-r2-upload.sh`. - **Spelling changes inside the moved JS, and only these:** `require('fs')` / `require('path')` became static imports. `process.env` became an `env` parameter that defaults to `process.env`. The two `new Date()` reads that stamp a body became an injectable `now` that defaults to `new Date()`. The import of `scheduled-full-run.mjs` became static. - **Wiring:** one new gate step in `lint-ci-engine`, `Moved workflow step bodies self-test` (with an `id:` and `continue-on-error: true`, so the lane verdict reads it), runs all three self-tests through the repo's `run_self_test` collector, so one red does not hide the others. - **Adoption lists:** `board-snapshot.yml`'s header and `scripts/pm/board-snapshot.mjs`'s docblock both said "copy FOUR files". The R2 step now needs a fifth, so both lists name it. That is the one prose edit, and it is the card's own carve-out: a literal the touched step makes false. - **Found while moving:** the root `.gitignore` ignores `test-*.mjs`, so the nightly module could not be named `test-nightly-tiers-card.mjs`. Its first push silently left it out, and it is now `nightly-tiers-card.mjs`. ## Tests Behaviour preservation was checked before each move, against the inline bodies at `6390fe52` (main when this branch was cut): - **github-script bodies:** a one-off harness ran the inline body extracted from the YAML (compiled the way `actions/github-script` compiles it) and then the module, under identical doubles, files and a frozen clock. It recorded every API call with its full parameters, bodies included, and every `core` call. The traces are identical: `scheduled-full-run-card` 7/7 scenarios, `nightly-tiers-card` 7/7. The committed self-tests pin the values taken from those traces. For the scheduled card the body is pinned as exact `renderBody(...)` arguments, so a wording change in `scheduled-full-run.mjs` does not red this file. For the nightly card it is pinned as the SHA-256 of the inline body's bytes. - **bash body:** the committed self-test passes against the inline block (extracted from the YAML at `6390fe52`) and against the moved script. Negative control: with `--delete` dropped from the sync line, two checks go red. - Each self-test carries a battery roster with per-case floors, a pinned roster size and a verdict handshake (AGENTS.md, "Writing a `--self-test`"). Derived gate families: `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` gives 60 commands. Results are below. CI runs the rest. **60 derived, 60 run, 0 red.** `node scripts/pm/dispatch-gates.mjs --ran ran.list` gives `60 derived famil(ies) accounted for — 60 run, 0 NOT-MEASURED`. The first round had 4 reds, all caused by this diff and fixed in `b422bbdf`: - `pnpm check:bash32-floor`: the R2 self-test's roster used `declare -A`. It now uses parallel indexed arrays. - `node scripts/check-step-collectors.mjs` (and its `--self-test`): the new lint step ran its three self-tests as a bare sequence. It now uses the `run_self_test` collector. - `pnpm check:declared-population-live`: the nightly self-test spelled its `$RUNNER_TEMP` fixture paths as literals, so they read as a declared population. It now builds them and carries a `no-path-population` marker. Not in the derived list, run by hand: `bash scripts/ci/board-snapshot-r2-upload.selftest.sh` passes (5 cases, 27 checks), and the new lint step's `run:` block, extracted from the YAML and run under `bash -e`, prints three `PASS` lines. <details><summary>Second-round results (exit code, command, seconds)</summary> ``` 0 node scripts/check-aggregator-roster.mjs 0s 0 node scripts/check-aggregator-roster.mjs --self-test 2s 0 node scripts/check-ci-filter-parity.mjs 0s 0 node scripts/check-closing-keyword-parity.mjs 2s 0 node scripts/check-closing-keyword-parity.mjs --self-test 5s 0 node scripts/check-comment-mask-corpus.mjs 181s 0 node scripts/check-declaration-mirrors.mjs 1s 0 node scripts/check-declaration-mirrors.mjs --self-test 0s 0 node scripts/check-merged-branch-reaper-outcome.mjs 0s 0 node scripts/check-merged-branch-reaper-outcome.mjs --self-test 0s 0 node scripts/check-scripts-symbol-anchors.mjs 3s 0 node scripts/check-scripts-symbol-anchors.mjs --self-test 4s 0 node scripts/check-self-test-wired.mjs 2s 0 node scripts/check-self-test-wired.mjs --self-test 5s 0 node scripts/check-self-test-workflow-commands.mjs 116s 0 node scripts/check-self-test-workflow-commands.mjs --self-test 3s 0 node scripts/check-step-collectors.mjs 1s 0 node scripts/check-step-collectors.mjs --self-test 5s 0 node scripts/check-whole-set-label-write.mjs 2s 0 node scripts/check-whole-set-label-write.mjs --self-test 1s 0 node scripts/ci/nightly-tiers-card.mjs --self-test 0s 0 node scripts/ci/scheduled-full-run-card.mjs --self-test 0s 0 node scripts/ci/scheduled-full-run.mjs --self-test 0s 0 node scripts/docs-audit/check-drift-comment.mjs 1s 0 node scripts/nightly-tiers.mjs --self-test 0s 0 node scripts/pm/bare-root-worklist.mjs --self-test 30s 0 node scripts/pm/board-snapshot.mjs --self-test 1s 0 node scripts/pm/ci-failure.mjs --self-test 0s 0 pnpm check:agent-test-spelling 3s 0 pnpm check:bash32-floor 2s 0 pnpm check:cli-command-ids 17s 0 pnpm check:cross-package-test-inputs 33s 0 pnpm check:declared-population-live 36s 0 pnpm check:driver-memory-census 5s 0 pnpm check:entry-guard 38s 0 pnpm check:gitlink-declared 1s 0 pnpm check:node-version 1s 0 pnpm check:nul-bytes 4s 0 pnpm check:parse-guard 3s 0 pnpm check:pm-board-snapshot 2s 0 pnpm check:pm-dispatch-gates 396s 0 pnpm check:pm-expected-skips 6s 0 pnpm check:pm-governed-merges 9s 0 pnpm check:pm-post-stamped 1s 0 pnpm check:pm-record-recognisers 4s 0 pnpm check:pnpm-acquisition 1s 0 pnpm check:pnpm-filter-targets 3s 0 pnpm check:pnpm-flag-spellings 6s 0 pnpm check:ratchet-remedy-authority 4s 0 pnpm check:refd-timer-probe 23s 0 pnpm check:required-contexts 4s 0 pnpm check:select-gate-families 4s 0 pnpm check:shard-attestation 3s 0 pnpm check:stall-guard-budget 1s 0 pnpm check:stall-guard-headroom 5s 0 pnpm check:type-check-coverage 5s 0 pnpm check:type-check-debt 6s 0 pnpm check:watch-hint-literal 7s 0 pnpm check:workflow-status-functions 1s 0 pnpm check:workflow-step-name-quoting 1s ``` </details> ## Acceptance notes **The card's premise holds for a minority of the volume.** On `6390fe52`: 20 inline bodies over 80 lines, 4,414 lines. Most of those lines are already exercised byte-for-byte by a gate that reads the YAML, so moving them buys no test reach and means rewriting the harness: | Body (lines) | What already reads its bytes | Status | |---|---|---| | merge-queue-triage · Post the triage comment (901) | `check-merge-queue-triage-outcome.mjs` compiles it as github-script does, 28 mutations; its P0 also pins that the job holds no `contents:` grant, which a checkout for an import would need | stays | | docs-drift-check · Comment on PR (597) | `docs-audit/check-drift-comment.mjs` runs it against fixture diffs | stays | | cross-repo-issue-closer (416) | `check-cross-repo-closer-outcome.mjs` (executes), `check-closing-keyword-parity.mjs` (mutates its regex) | stays | | release.yml · Audit the release (277) | `release-verify-npm.mjs` batteries 12/13 run the step text | stays | | merged-branch-reaper (174) | `check-merged-branch-reaper-outcome.mjs` (executes) | stays | | shard-timings-refresh · Choose (82), Regenerate (136) | `ci/select-shard-timings-run.mjs` runs both | stays | | shard-timings-refresh · Compose (206) | text pin in `ci/select-shard-timings-run.mjs` | stays | | shard-timings-refresh · Push (102) | executed for real on every `pull_request` run of the lane (the `DRY_RUN` leg) | stays | | pr-automation · Require a changeset (167) | text pins in `check-empty-changeset.mjs` | stays | | platform-checklist-watchdog (145) | `check-platform-checklist-watchdog.mjs` scans the script for board writes; moving it would blind that negative pin | stays | | duplicate-fix-guard (108) | `check-closing-keyword-parity.mjs` mutates its regex | stays | | release.yml · Guard the approved release (99) | trust boundary: the `publish` job checks out the VERSION COMMIT, so a moved guard would be read from the tree it is guarding | stays | | release.yml · Render (84), Validate (167) the post-version tree | trust boundary: both run after `changeset version` has written the working tree, so a moved validator would be read from the tree it validates | stays | | ci.yml · Run this shard's tests (185) | the step #26084 is changing; moves after it lands | deferred | | cut-rc.yml · Build the single version commit (164) | the file is deleted by #26196 | out | | the three above | nothing | **moved here** | After this PR: 17 bodies over 80 lines, 4,010 lines (the census re-run on the branch). Whether the harnessed bodies should move anyway, which means rewriting about ten harnesses and granting `contents: read` to jobs that hold none today, is the open scope question for this card's PR 1. It is in front of the maintainer in the project thread. This PR is the part both answers need. ## Verification ```sh node scripts/ci/scheduled-full-run-card.mjs --self-test node scripts/ci/nightly-tiers-card.mjs --self-test bash scripts/ci/board-snapshot-r2-upload.selftest.sh ``` Tier H (`.github/workflows/**`): draft until a GOVERNED_APPROVERS approval (os-zhuang, hotlong). --- _Generated by [Claude Code](https://claude.ai/code/session_01ADHkmQq9EYQdTQwmRutQTS)_ 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01ADHkmQq9EYQdTQwmRutQTS --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent b33dd25 commit 671df26

9 files changed

Lines changed: 1348 additions & 410 deletions

‎.github/workflows/board-snapshot.yml‎

Lines changed: 7 additions & 129 deletions
Original file line numberDiff line numberDiff line change
@@ -118,10 +118,11 @@ name: Board Snapshot
118118
#
119119
# ## Adopting this in a sibling repo
120120
#
121-
# Copy FOUR files, unchanged: `scripts/pm/board-snapshot.mjs`,
121+
# Copy FIVE files, unchanged: `scripts/pm/board-snapshot.mjs`,
122122
# `scripts/pm/check-half-states.mjs` (the archiver imports its proxy-re-exec plan
123-
# and its repo resolver), `scripts/invoked-as.mjs` (imported by both) and this
124-
# file. A repo that has already adopted the half-state patrol has the middle two.
123+
# and its repo resolver), `scripts/invoked-as.mjs` (imported by both),
124+
# `scripts/ci/board-snapshot-r2-upload.sh` (the R2 step runs it) and this
125+
# file. A repo that has already adopted the half-state patrol has the second and third.
125126
# ⛔ Do not shorten this list from memory — the imports decide it, not this
126127
# comment, and the patrol's own adopt list was wrong for months in exactly that
127128
# way, which installed a dead sweeper that failed before its first predicate.
@@ -392,132 +393,9 @@ jobs:
392393
AWS_REQUEST_CHECKSUM_CALCULATION: when_required
393394
R2_ACCOUNT_ID: ${{ secrets.R2_ACCOUNT_ID }}
394395
R2_BUCKET: ${{ secrets.R2_BUCKET }}
395-
run: |
396-
set -euo pipefail
397-
398-
if [ -z "${AWS_ACCESS_KEY_ID:-}" ] || [ -z "${AWS_SECRET_ACCESS_KEY:-}" ] \
399-
|| [ -z "${R2_ACCOUNT_ID:-}" ] || [ -z "${R2_BUCKET:-}" ]; then
400-
echo "status=skipped — no R2 credentials configured in this repository" >> "$GITHUB_OUTPUT"
401-
echo "::notice::R2 upload skipped: R2_ACCOUNT_ID, R2_BUCKET, R2_ACCESS_KEY_ID and R2_SECRET_ACCESS_KEY are not all set as repository secrets; this run is archived on the board-archive branch only."
402-
exit 0
403-
fi
404-
405-
endpoint="https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com"
406-
mirror="s3://${R2_BUCKET}/objectstack/board/"
407-
408-
# The SOURCE is the snapshot directory, NOT the worktree root that
409-
# contains it. The snapshot above is written to `archive/board`, so
410-
# syncing `archive/` would map `archive/board/X` onto the `mirror`
411-
# prefix as `objectstack/board/` + `board/X` — one segment deeper
412-
# than the prefix on the line above says the mirror is, and visible
413-
# only from the bucket side. Syncing `archive/board/` maps
414-
# `archive/board/X` to `objectstack/board/X`, which is the layout a
415-
# restore driven from the documented path looks for.
416-
#
417-
# `--delete` is kept and needs no migration step of its own: the
418-
# stale `objectstack/board/board/` tree is inside this sync target
419-
# and has no local counterpart, so the first run after this change
420-
# removes it. ⛔ The target is never widened to `objectstack/` to
421-
# achieve the same mapping — that would put `objectstack/snapshots/`
422-
# inside a `--delete` target and eat the daily tarballs, the failure
423-
# the comment above already warns about.
424-
#
425-
# No `.git` filter is needed here: `archive/` is a linked worktree,
426-
# so its `.git` is a FILE at `archive/.git`, which is outside this
427-
# source (filters match paths relative to the source directory).
428-
#
429-
# mtimes first: see "Only changed records are uploaded" above. The
430-
# history is walked newest-first, so the first time a path appears
431-
# is the last commit that touched it; a path whose newest commit
432-
# deleted it has no file and is skipped. `-z` keeps every byte of a
433-
# path intact. A truncated history errs in the safe direction only:
434-
# its oldest commit looks like the one that created every file older
435-
# than it, which stamps those files NEWER than their real last
436-
# change, so they are uploaded again, and no stale object is ever
437-
# skipped.
438-
#
439-
# The script is written to a file rather than fed through a heredoc
440-
# inside the command substitution, which keeps bash's parser out of
441-
# the backticks and quotes it contains.
442-
cat > "$RUNNER_TEMP/archive-mtimes.cjs" <<'JS'
443-
const { execFileSync } = require('node:child_process');
444-
const { utimesSync } = require('node:fs');
445-
const { join } = require('node:path');
446-
const git = (...args) =>
447-
execFileSync('git', ['-C', 'archive', ...args], { encoding: 'utf8', maxBuffer: 1 << 30 });
448-
const log = git('log', '--no-renames', '--name-only', '-z', '--format=%x01%ct', 'HEAD', '--', 'board');
449-
const seen = new Set();
450-
let time = NaN;
451-
let stamped = 0;
452-
for (const token of log.split('\0')) {
453-
if (token.startsWith('\x01')) { time = Number(token.slice(1)); continue; }
454-
const path = token.replace(/^\n/, '');
455-
if (!path || seen.has(path)) continue;
456-
seen.add(path);
457-
if (!Number.isInteger(time)) throw new Error(`no commit time read before ${path}`);
458-
try {
459-
utimesSync(join('archive', path), time, time);
460-
stamped += 1;
461-
} catch (e) {
462-
if (e.code !== 'ENOENT') throw e;
463-
}
464-
}
465-
const tracked = git('ls-files', '-z', '--', 'board').split('\0').filter(Boolean).length;
466-
if (stamped !== tracked) throw new Error(`stamped ${stamped} file(s) but the archive tracks ${tracked}`);
467-
const commits = git('rev-list', '--count', 'HEAD').trim();
468-
console.log(`${stamped} file(s) stamped from ${commits} commit(s)`);
469-
JS
470-
if mtimes=$(node "$RUNNER_TEMP/archive-mtimes.cjs" 2>&1); then
471-
# The last line only: the status line below goes into
472-
# $GITHUB_OUTPUT, where a second line would break its format.
473-
mtime_note="mtimes from history: ${mtimes##*$'\n'}"
474-
echo "${mtime_note}"
475-
else
476-
printf '%s\n' "$mtimes" >&2
477-
echo "::warning::Could not set the archive's mtimes from its git history (output above), so this sync re-uploads every object. The copy stays complete, but every file costs a Class A operation."
478-
mtime_note="FULL re-upload, mtimes not restored"
479-
fi
480-
481-
# No pipe between the command and `$?` — see the snapshot step.
482-
set +e
483-
aws s3 sync archive/board/ "$mirror" \
484-
--endpoint-url "$endpoint" \
485-
--delete --no-progress \
486-
> "$RUNNER_TEMP/r2-sync.log" 2>&1
487-
code=$?
488-
set -e
489-
if [ "$code" != "0" ]; then
490-
cat "$RUNNER_TEMP/r2-sync.log" >&2
491-
echo "status=FAILED — aws s3 sync exited $code" >> "$GITHUB_OUTPUT"
492-
echo "::error::aws s3 sync to R2 exited $code. This run IS archived on board-archive; the out-of-GitHub copy is now behind and every later run will stay behind until this is fixed."
493-
exit 1
494-
fi
495-
# One line per object moved. With the mtimes restored above, a run
496-
# moves the records that changed since the last upload; a run that
497-
# fell back (or the first one configured) moves the whole board, so
498-
# the log is truncated here and the counts below are taken over all
499-
# of it.
500-
transfers=$(wc -l < "$RUNNER_TEMP/r2-sync.log")
501-
head -n 50 "$RUNNER_TEMP/r2-sync.log"
502-
if [ "$transfers" -gt 50 ]; then
503-
echo "... and $((transfers - 50)) further transfer line(s), not printed."
504-
fi
505-
uploaded=$(grep -c '^upload:' "$RUNNER_TEMP/r2-sync.log" || true)
506-
deleted=$(grep -c '^delete:' "$RUNNER_TEMP/r2-sync.log" || true)
507-
508-
day=$(date -u +%Y-%m-%d)
509-
tarball="s3://${R2_BUCKET}/objectstack/snapshots/${day}.tar.gz"
510-
if aws s3 ls "$tarball" --endpoint-url "$endpoint" > /dev/null 2>&1; then
511-
tarred="already written for ${day}"
512-
else
513-
tar -czf "$RUNNER_TEMP/${day}.tar.gz" --exclude=.git -C archive .
514-
aws s3 cp "$RUNNER_TEMP/${day}.tar.gz" "$tarball" \
515-
--endpoint-url "$endpoint" --no-progress
516-
tarred="written for ${day}"
517-
fi
518-
519-
echo "status=synced ${uploaded} object(s), deleted ${deleted} (${mtime_note}); daily tarball ${tarred}" >> "$GITHUB_OUTPUT"
520-
echo "R2: synced ${uploaded} object(s), deleted ${deleted} (${mtime_note}); daily tarball ${tarred}"
396+
# The body lives in scripts/ci/board-snapshot-r2-upload.sh (#26086); its
397+
# offline self-test is scripts/ci/board-snapshot-r2-upload.selftest.sh.
398+
run: bash scripts/ci/board-snapshot-r2-upload.sh
521399

522400
- name: Publish the run to the summary
523401
if: always()

‎.github/workflows/lint.yml‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3473,6 +3473,38 @@ jobs:
34733473
node scripts/ci/scheduled-full-run.mjs --self-test
34743474
node scripts/ci/scheduled-full-run.mjs --check-concurrency
34753475
3476+
# Workflow step bodies that moved out of inline `run:` / `github-script`
3477+
# blocks into scripts/ci/ (#26086). Each step fires only on an event no
3478+
# pull request produces (a red scheduled run, a red nightly on main), so
3479+
# its module's --self-test is the one place it runs before it fires.
3480+
# Offline doubles; sub-second each. A collector, so one red self-test
3481+
# does not hide the others (#10814).
3482+
- name: Moved workflow step bodies self-test
3483+
id: moved-workflow-step-bodies-self-test
3484+
continue-on-error: true
3485+
run: |
3486+
failed=""
3487+
run_self_test() {
3488+
echo "-- $*"
3489+
if "$@"; then
3490+
echo "PASS $*"
3491+
else
3492+
echo "FAIL $*"
3493+
failed="${failed} $*"$'\n'
3494+
fi
3495+
return 0
3496+
}
3497+
run_self_test node scripts/ci/scheduled-full-run-card.mjs --self-test
3498+
run_self_test node scripts/ci/nightly-tiers-card.mjs --self-test
3499+
run_self_test bash scripts/ci/board-snapshot-r2-upload.selftest.sh
3500+
if [ -n "$failed" ]; then
3501+
echo ""
3502+
echo "Moved workflow step bodies self-tests — the following FAILED:"
3503+
printf "%s" "$failed"
3504+
exit 1
3505+
fi
3506+
echo "Moved workflow step bodies self-tests — all three ran and passed"
3507+
34763508
# Gate-family selection self-test (#16496). The "Select the gate families
34773509
# this run pays for" step at the top of this job is a thin call into
34783510
# scripts/ci/select-gate-families.sh, and its `merge_group` branch cannot

‎.github/workflows/scheduled-full-run-card.yml‎

Lines changed: 4 additions & 121 deletions
Original file line numberDiff line numberDiff line change
@@ -161,127 +161,10 @@ jobs:
161161
retries: 3
162162
retry-exempt-status-codes: 400,401,404,422
163163
script: |
164-
const mod = await import(`${process.env.GITHUB_WORKSPACE}/scripts/ci/scheduled-full-run.mjs`);
165-
const { owner, repo } = context.repo;
166-
const run = context.payload.workflow_run;
167-
168-
const identity = mod.cardIdentity(run.name);
169-
const runUrl = run.html_url;
170-
171-
// The previous GREEN scheduled run of THIS workflow. `status:
172-
// 'success'` is the filter that matters: a cancelled or a red
173-
// earlier run is not a point the tree was known good at, so a range
174-
// measured from one would name commits that were already suspect.
175-
let previousGreen = null;
176-
let commits = [];
177-
let compareUrl = null;
178-
let commitsTruncated = false;
179-
let rangeNote = null;
180-
try {
181-
const previous = await github.rest.actions.listWorkflowRuns({
182-
owner, repo,
183-
workflow_id: run.workflow_id,
184-
event: 'schedule',
185-
branch: 'main',
186-
status: 'success',
187-
per_page: 1,
188-
});
189-
const hit = previous.data.workflow_runs?.[0];
190-
if (hit) previousGreen = { run_id: hit.id, head_sha: hit.head_sha };
191-
else rangeNote = 'No previous GREEN `schedule` run of this workflow is in the API window — either this is the first one, or every scheduled run in the window was red.';
192-
} catch (error) {
193-
rangeNote = `The previous-green lookup failed (${error.message}).`;
194-
}
195-
196-
if (previousGreen) {
197-
try {
198-
const cmp = await github.rest.repos.compareCommitsWithBasehead({
199-
owner, repo,
200-
basehead: `${previousGreen.head_sha}...${run.head_sha}`,
201-
});
202-
commits = (cmp.data.commits ?? []).map((c) => ({
203-
sha: c.sha,
204-
title: String(c.commit?.message ?? '').split('\n')[0],
205-
}));
206-
compareUrl = cmp.data.html_url ?? null;
207-
// The compare endpoint caps at 250 commits. Saying so beats
208-
// presenting a truncated list as the whole hour.
209-
commitsTruncated = (cmp.data.total_commits ?? commits.length) > commits.length;
210-
} catch (error) {
211-
// ⛔ Do NOT fall through to "nothing landed": that string is the
212-
// FLAKE reading, and a failed lookup is not evidence of an
213-
// empty range. Drop the range entirely and say why.
214-
previousGreen = null;
215-
rangeNote = `The commit range could not be read (${error.message}).`;
216-
}
217-
}
218-
219-
const body = mod.renderBody({
220-
identity,
221-
runUrl,
222-
headSha: run.head_sha,
223-
conclusion: run.conclusion,
224-
sweptAt: new Date().toISOString(),
225-
previousGreen,
226-
commits,
227-
compareUrl,
228-
commitsTruncated,
229-
rangeNote,
230-
});
231-
232-
// The DECISION is the module's, driven offline by its self-test
233-
// against a mutable board (one red run files one card; the next
234-
// refreshes it and files no second; a refresh of a card whose
235-
// every blocker is a closed LOCAL card moves `pm:blocked` to
236-
// `pm:queue`). What is left here is thin callbacks over the API.
237-
const outcome = await mod.fileOrRefreshCard({
238-
identity,
239-
body,
240-
ownerRepo: `${owner}/${repo}`,
241-
perPage: 100,
242-
maxPages: 10,
243-
listPage: async (page) => {
244-
const res = await github.rest.issues.listForRepo({
245-
owner, repo, state: 'open', sort: 'created', direction: 'asc', per_page: 100, page,
246-
});
247-
return res.data;
248-
},
249-
updateIssue: async ({ number, body: b }) => {
250-
await github.rest.issues.update({ owner, repo, issue_number: number, body: b });
251-
},
252-
createIssue: async ({ title, body: b, labels }) => {
253-
// Additive on create; ⛔ nothing here ever replaces a whole
254-
// label set (`check:whole-set-label-write` refuses that verb).
255-
const res = await github.rest.issues.create({ owner, repo, title, body: b, labels });
256-
return { number: res.data.number };
257-
},
258-
// Read only for a card carrying `pm:blocked`.
259-
listComments: async ({ number, page, perPage }) => {
260-
const res = await github.rest.issues.listComments({
261-
owner, repo, issue_number: number, per_page: perPage, page,
262-
});
263-
return res.data;
264-
},
265-
getIssue: async ({ repo: target, number }) => {
266-
const [targetOwner, targetRepo] = target.split('/');
267-
const res = await github.rest.issues.get({ owner: targetOwner, repo: targetRepo, issue_number: number });
268-
return { state: res.data.state, closed_at: res.data.closed_at };
269-
},
270-
// The swap: an additive POST and ONE remove by name.
271-
addLabels: async ({ number, labels }) => {
272-
await github.rest.issues.addLabels({ owner, repo, issue_number: number, labels });
273-
},
274-
removeLabel: async ({ number, name }) => {
275-
await github.rest.issues.removeLabel({ owner, repo, issue_number: number, name });
276-
},
277-
});
278-
279-
core.info(`${outcome.action} ${identity.marker} card #${outcome.number} (${body.length} chars)`);
280-
if (outcome.block) core.info(`block: ${outcome.blockSummary}`);
281-
const moved = outcome.block === 'expired' ? ' (every blocker closed: pm:blocked moved to pm:queue)' : '';
282-
core.notice(`${run.name} is red on the scheduled full run — card #${outcome.number} ${outcome.action}${moved}.`);
283-
core.setOutput('action', outcome.action);
284-
core.setOutput('number', String(outcome.number));
164+
// The body lives in scripts/ci/scheduled-full-run-card.mjs (#26086), driven
165+
// offline by its --self-test; this step hands it the same github/context/core.
166+
const { fileCardStep } = await import(`${process.env.GITHUB_WORKSPACE}/scripts/ci/scheduled-full-run-card.mjs`);
167+
await fileCardStep({ github, context, core });
285168
286169
- name: Publish the verdict to the run summary
287170
# always(): when the step above threw — a refused scan, a rate limit —

0 commit comments

Comments
 (0)