-
Notifications
You must be signed in to change notification settings - Fork 1
745 lines (704 loc) · 45 KB
/
Copy pathexamples-ci.yml
File metadata and controls
745 lines (704 loc) · 45 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
name: Examples
on:
push:
branches: [ "**" ]
pull_request:
branches: [ "master" ]
# A new release on Maven Central produces no push and no PR here, so nothing
# would notice that every downstream pin just fell one release further behind
# — or that the docs invariant broke without anyone touching this repository.
# Only a timer catches that moment.
#
# `schedule` runs from the default branch only, so this takes effect once it
# is on master.
schedule:
- cron: '0 3 * * 1'
# For the manual run before cutting a release.
workflow_dispatch:
jobs:
compile:
name: Compile documentation examples
runs-on: ubuntu-latest
# Only checks out the repo and runs `mvn compile` — no GitHub API writes
# happen here, so this job gets nothing beyond read access to contents.
# It does NOT inherit version-consistency's `issues: write`; permissions
# declared per job, not once at the top of this file (see that job's
# permissions block for why a top-level declaration would be wrong here).
permissions:
contents: read
steps:
- name: Checkout
uses: actions/checkout@v4
with:
# 构建期会执行仓库外的代码,不持久化凭据可避免 token 落到 .git/config
persist-credentials: false
- name: Set up JDK 21
uses: actions/setup-java@v4
with:
java-version: '21'
distribution: 'temurin'
cache: maven
- name: Compile examples against released UltiTools-API
run: mvn -B -f examples/pom.xml compile
version-consistency:
name: Docs version matches latest release
runs-on: ubuntu-latest
# WR-01 fix: without this, two runs of this job can execute in parallel —
# e.g. a `push` to master landing at the same wall-clock moment as the
# weekly `schedule` cron, or two pushes to master in quick succession.
# Both runs would query `gh issue list --state open --label release-sync`
# before either has created one, both see an empty result, and both call
# `gh issue create` — this is the actual mechanism that produces the
# duplicate-issue scenario CR-01's fix has to clean up after the fact.
#
# PR #39 round-3 review fix (replaces the earlier fixed-string
# `group: release-sync-status-light`): a single fixed group covers EVERY
# trigger of this job, not just the three that can mutate the
# `release-sync` issue. The "Open or update"/"Close if recovered" steps
# below both gate on event via `if:` and intentionally do nothing for a
# feature-branch `push` or a `pull_request` run — but a fixed-string
# `concurrency.group` does not know that. GitHub Actions allows at most
# one PENDING run per group, and queuing a new run into an occupied
# group CANCELS whatever was already pending there. So a feature-branch
# push queued behind a pending `schedule` run would cancel that
# scheduled run outright — the mutating run silently never executes, a
# drift that week opens no issue, a recovery that week closes none, and
# nothing in the Actions log calls this out as a failure because the
# cancelled run's steps never even started. That is strictly worse than
# the duplicate-issue bug this block was added to fix: a duplicate issue
# is visible and self-correcting (CR-01's own dedupe/close-all logic
# cleans it up on the next green run); a silently skipped mutation is
# invisible and does not self-correct — GATE-07's whole premise is that
# this status light's state can be trusted, and "did the check even run"
# failing silently breaks that premise more fundamentally than a stray
# duplicate ever did.
#
# Fix: only the three event/ref combinations that can reach the mutating
# steps below share the fixed group; every other event computes a group
# name unique to its own run (`github.run_id`), so it is alone in its
# own group — nothing else can be pending there to cancel, and it can
# never displace a pending mutating run either. The mutating events
# (`schedule`, `workflow_dispatch`, `push` to `refs/heads/master`) still
# funnel into the one shared `release-sync-status-light` group, so
# WR-01's original guarantee — those three still serialize against each
# other — holds unchanged.
#
# Alternatives considered and rejected:
# - Scope the group by `github.ref` (e.g.
# `release-sync-status-light-${{ github.ref }}`). Rejected: this
# stops feature branches from cancelling the scheduled run, but it
# also un-serializes the three mutating triggers from each other
# whenever, say, `workflow_dispatch` is run against a non-master ref
# — the exact race WR-01 exists to close would reopen for that
# combination.
# - Drop `concurrency` from this job entirely and rely on the existing
# dedupe logic (CR-01's sort-by-number-and-close-all, D-02/D-03's
# open-issue dedupe) to clean up after races instead of preventing
# them. Rejected: dedupe cleans up *duplicate* issues; it does
# nothing for the cancelled-run case, which produces no issue and no
# signal at all — there is nothing to deduplicate.
# - Set `cancel-in-progress: true` on the shared group. Rejected: the
# comment on `cancel-in-progress: false` below already establishes
# why a mid-`run:` cancellation is unacceptable here (a cancelled
# run can leave an issue open/created/edited with no later step ever
# closing it) — switching it to `true` would make that failure mode
# the common case instead of a rare one.
concurrency:
group: ${{ (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' || (github.event_name == 'push' && github.ref == 'refs/heads/master')) && 'release-sync-status-light' || format('release-sync-status-light-solo-{0}', github.run_id) }}
cancel-in-progress: false
# This file had no `permissions` block before this Phase, so both jobs
# inherited the repository's default token permissions (confirmed via
# `gh api repos/UltiKits/UltiTools-Dev-Doc/actions/permissions/workflow`:
# `default_workflow_permissions: write`). Declaring per job — not once at
# the top of the file — is the point of this hardening: `compile` and
# this job need different capabilities, and one shared top-level block
# would hand `compile` a write scope it never uses.
#
# `issues: write` is here because this job's entire output IS a GitHub
# issue object — opening/updating/closing `release-sync`.
# `contents: read` has to be declared right alongside it: once ANY
# permission key is declared, every other permission implicitly becomes
# `none`, and dropping `contents: read` here would silently break
# `actions/checkout@v4` two lines below with no obvious error pointing
# back at this block.
# These two keys are the whole of what this job needs — no
# `pull-requests`, no `contents: write`.
permissions:
contents: read
issues: write
steps:
- name: Checkout
uses: actions/checkout@v4
with:
# 构建期会执行仓库外的代码,不持久化凭据可避免 token 落到 .git/config
persist-credentials: false
# The script prints two things: the invariant (which decides the exit
# code) and an observational matrix of where downstream repositories pin
# UltiTools-API. The matrix reads other repositories through `gh`, so it
# needs a token; without one it says so rather than showing an empty table
# that reads like "nothing is lagging".
#
# `id: check` is required for the "Open or update release-sync issue"
# step below to read this step's $GITHUB_OUTPUT values via
# `steps.check.outputs.*` — without it, that expression resolves to an
# empty string with no error, so the issue would still open but its
# body's version-number column would be silently blank.
- name: Check version invariant and report downstream pins
id: check
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: ./scripts/check-version-consistency.sh
# D-31: reuses this job's own weekly schedule instead of introducing a
# second trigger mechanism. Event gate matches the two release-sync
# issue steps exactly (D-14's reasoning applies unchanged: a fork PR's
# token is read-only, and a feature-branch push shouldn't trigger a
# real request against a third party either). `success()` is written
# explicitly because a custom `if:` on this step means Actions no
# longer implicitly appends it (RESEARCH Anti-Pattern 3, same note as
# the release-sync steps below) — indexing a release only makes sense
# once the version invariant itself held.
#
# The target version is passed via `env:`, not interpolated into
# `run:` — the value originates from Maven Central metadata one step
# up and this avoids ever building a shell command by string
# substitution from data this workflow doesn't control the format of.
#
# Exit codes are dispatched explicitly in `run:`, not with the
# step-level "ignore my own failure" switch: that switch would swallow
# exit 1 (D-34, upstream forms changed — a permanent problem, must
# fail this step) and exit 2 (D-32, poll timeout — a transient one,
# must not) into the same "ignored" bucket, which is exactly the
# distinction these two
# decisions exist to preserve. `set +e`/`set -e` around the single
# invocation is needed because GitHub's default shell for `run:`
# already runs with `-e`, which would abort this step before the
# exit-code dispatch below ever ran otherwise.
- name: Trigger javadoc.io indexing for the current release
id: index
if: |
success() &&
(github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' ||
(github.event_name == 'push' && github.ref == 'refs/heads/master'))
env:
TARGET_VERSION: ${{ steps.check.outputs.central_ver }}
run: |
set +e
bash scripts/javadoc-io-index.sh --version "$TARGET_VERSION"
index_exit=$?
set -e
case "$index_exit" in
0)
echo "javadoc.io 索引:就绪或无事可做(exit 0)。"
;;
2)
echo "javadoc.io 索引:本次轮询超时、取版本列表页失败,或 POST(sync/upload)失败(exit 2,见 WR-02, 02-REVIEW.md),视为第三方队列慢或暂态 HTTP 错误,不判本步骤失败。"
;;
1)
echo "::error::javadoc.io 索引脚本判定上游版本列表页结构变了(exit 1),脚本需要重写。"
exit 1
;;
*)
echo "::error::javadoc.io 索引脚本返回了未预期的退出码 ${index_exit}。"
exit 1
;;
esac
# D-32/D-34: one status light, mutually exclusive causes, worded
# differently so neither ever asserts something false — mirrors the
# release-sync issue's own D-12 handling of `mismatch`/`ahead` needing
# a qualifying sentence. A NEW label (`javadoc-index`), not
# `release-sync`'s: that issue's body carries the sentence "未关闭即
# 代表文档站落后于最新正式版" (GATE-07's entire value), and an index
# timeout is not a version-lag condition — reusing the label would
# make that sentence false the moment this fires.
#
# `post-failed` (WR-02, 02-REVIEW.md) is a third, distinct cause: a
# POST (sync or upload) returned an upstream HTTP error before
# polling ever started. It is NOT folded into `timeout` — the
# `timeout` directional_note below says "本次轮询超时", which would
# be a false statement about a run where no polling happened at all.
#
# `always()`: the previous step legitimately FAILS (exit 1) on the
# `broken` outcome, and this step must still run in that case to open
# the diagnostic issue — without `always()`, a custom `if:` on this
# step would default to running only on success and this step would
# never fire for exactly the case it exists to report.
- name: Open or update javadoc-index issue
if: |
always() &&
(github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' ||
(github.event_name == 'push' && github.ref == 'refs/heads/master')) &&
(steps.index.outputs.index_result == 'timeout' || steps.index.outputs.index_result == 'broken' ||
steps.index.outputs.index_result == 'post-failed' || steps.index.outputs.index_result == 'fetch-failed')
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_REPO: ${{ github.repository }}
TARGET_VERSION: ${{ steps.check.outputs.central_ver }}
INDEX_RESULT: ${{ steps.index.outputs.index_result }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: |
gh label create javadoc-index \
--color fbca04 \
--description "javadoc.io 索引触发的暂态/结构问题,与 release-sync 分开的状态灯" >/dev/null 2>&1 || true
case "$INDEX_RESULT" in
timeout)
directional_note="javadoc.io 的索引作业本次轮询超时——这是第三方队列慢,不代表版本号落后或脚本坏了。下一个周期的 schedule 会自动重试,不需要人介入。"
;;
broken)
directional_note="javadoc.io 的版本列表页结构与本仓库索引脚本的预期不符——脚本需要重写,不会自动恢复,需要人介入。"
;;
post-failed)
directional_note="javadoc.io 的索引触发请求(sync 或 upload)收到了上游 HTTP 错误,轮询从未开始——判定为第三方服务暂态问题,不代表版本号落后或脚本坏了。下一个周期的 schedule 会自动重试,不需要人介入。"
;;
fetch-failed)
directional_note="取 javadoc.io 版本列表页的 GET 本身就没成功,索引触发请求还没来得及发出——判定为第三方服务或网络暂态问题,不代表版本号落后或脚本坏了。下一个周期的 schedule 会自动重试,不需要人介入。"
;;
esac
cat > "$RUNNER_TEMP/javadoc-index-body.md" <<BODY
${directional_note}
| | |
|---|---|
| 目标版本 | \`${TARGET_VERSION}\` |
| 结果 | \`${INDEX_RESULT}\` |
<!-- javadoc-index-state: version=${TARGET_VERSION} result=${INDEX_RESULT} -->
失败 run:${RUN_URL}
BODY
if ! existing_json=$(gh issue list --state open --label javadoc-index \
--limit 100 --json number,body); then
echo "::error::查询已有 javadoc-index 状态灯失败(网络或鉴权问题)。不吞掉这个失败,是为了不让它被误判为「没有已开状态灯」而新开一条重复 issue。"
exit 1
fi
existing_count=$(printf '%s' "$existing_json" | jq -r 'length')
if [ "$existing_count" -gt 1 ]; then
echo "::warning::发现 ${existing_count} 条同时处于 open 状态的 javadoc-index 状态灯,预期最多 1 条。本步骤只更新其中编号最小(最早开出)的一条;其余的会在恢复时由下面的关闭步骤统一关闭。"
fi
existing=$(printf '%s' "$existing_json" | jq -r 'sort_by(.number) | .[0].number // empty')
existing_body=$(printf '%s' "$existing_json" | jq -r 'sort_by(.number) | .[0].body // empty')
# G-02-19: these two extractions are deliberately NOT written the
# same way. `result` is this workflow's own closed enum (six
# all-lowercase, hyphenated literals — see the `case "$INDEX_
# RESULT"` block above and index_result's own set upstream), so
# enumerating its character class ([a-z-]) is an accurate match;
# the previous [a-z] omitted the hyphen and truncated
# `post-failed`/`fetch-failed` to `post`/`fetch`, which meant the
# equality check below never held and every schedule run with an
# unchanged result still edited the issue. `version`, by
# contrast, is decided by Maven Central, not by this repository —
# today's versions all happen to be three dot-separated numeric
# segments, but that is an observation, not a constraint this
# workflow should encode. It is written into the marker line
# above by this same step as "read to the next space", so
# extracting it the same way (up to the next space) is the form
# that stays correct regardless of what that alphabet looks like.
prev_version=$(printf '%s' "$existing_body" | grep -oE 'version=[^ ]+' | head -1 | cut -d= -f2) || prev_version=""
prev_result=$(printf '%s' "$existing_body" | grep -oE 'result=[a-z-]+' | head -1 | cut -d= -f2) || prev_result=""
if [ -z "$existing" ]; then
gh issue create \
--title "docs: javadoc.io 索引触发遇到问题" \
--label javadoc-index \
-F "$RUNNER_TEMP/javadoc-index-body.md"
elif [ "$prev_version" = "$TARGET_VERSION" ] && [ "$prev_result" = "$INDEX_RESULT" ]; then
echo "状态灯已在(issue #$existing),目标版本与结果均未变,跳过写操作。"
else
gh issue edit "$existing" -F "$RUNNER_TEMP/javadoc-index-body.md"
fi
# Mirrors release-sync's own "Close if recovered" step: no step-level
# "ignore my own failure" switch here either — a close failure must
# turn this otherwise-green job red rather than silently leave a status light on
# that looks indistinguishable from a genuine `broken`/`timeout`.
- name: Close javadoc-index issue if recovered
if: |
success() &&
(github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' ||
(github.event_name == 'push' && github.ref == 'refs/heads/master')) &&
(steps.index.outputs.index_result == 'ready' || steps.index.outputs.index_result == 'skipped')
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_REPO: ${{ github.repository }}
TARGET_VERSION: ${{ steps.check.outputs.central_ver }}
run: |
if ! existing_numbers=$(gh issue list --state open --label javadoc-index \
--limit 100 --json number --jq '.[].number'); then
echo "::error::查询已有 javadoc-index 状态灯失败(网络或鉴权问题)。不吞掉这个失败,是为了不让本来绿的 job 悄悄保持绿色,而状态灯其实还没被关掉。"
exit 1
fi
if [ -n "$existing_numbers" ]; then
while IFS= read -r existing; do
gh issue close "$existing" -c "目标版本 ${TARGET_VERSION} 已就绪或无事可做,本状态灯已恢复。"
done <<< "$existing_numbers"
fi
- name: Open or update release-sync issue
# Why `pull_request` is excluded: this is a public repo, fork PRs are
# a normal path, and a fork PR's GITHUB_TOKEN is always read-only, so
# this step would just fail there every time. Same reasoning rules
# out a plain feature branch too — a temporary version-number edit
# there shouldn't open a real issue on a public repo either (D-14).
#
# Why event name, not branch: `workflow_dispatch` runs the workflow
# definition of whichever branch was selected, and its `github.ref`
# at that point IS that branch, not master. Gating on `github.ref`
# instead of `github.event_name` would make the branch drill required
# by GATE-08 unable to ever reach this step (D-15).
#
# Why the status function must be spelled out explicitly: Actions
# only implicitly appends `success()` when a step has NO `if:` at
# all. Once a custom `if:` is written, that implicit append stops —
# omitting `failure()` here would let this step also run when Step 2
# succeeds (RESEARCH Anti-Pattern 3).
#
# Why `steps.check.outputs.kind` must be non-empty and non-`unknown`:
# exit code 2 (the three values couldn't even be parsed) still turns
# the job red, but must NOT open a status light — a single curl
# timeout opening an issue that says "still unresolved until closed"
# would be a false statement, and GATE-07's entire value rests on
# that sentence being trustworthy (D-11).
if: |
failure() &&
(github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' ||
(github.event_name == 'push' && github.ref == 'refs/heads/master')) &&
steps.check.outputs.kind != '' && steps.check.outputs.kind != 'unknown'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_REPO: ${{ github.repository }}
DOC_VER: ${{ steps.check.outputs.doc_ver }}
POM_VER: ${{ steps.check.outputs.pom_ver }}
CENTRAL_VER: ${{ steps.check.outputs.central_ver }}
KIND: ${{ steps.check.outputs.kind }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: |
# D-04: idempotent label create. Fails non-zero when the label
# already exists — that's the expected "already there" outcome, not
# an error. Must run before the query below: a newly created issue
# needs to carry this label, so the label has to exist first no
# matter what the query finds. Both streams are swallowed, not just
# stderr — most runs find the label already there, and a red-looking
# "already exists" line on every single run would drown out real
# failures in the Actions log.
gh label create release-sync \
--color b60205 \
--description "文档站版本与最新正式版不同步" >/dev/null 2>&1 || true
# D-12/GATE-07: three shapes of exit-1 share this one status light,
# but the GATE-07 sentence is only true as a plain statement for
# `behind`. For `mismatch`/`ahead` it still appears verbatim — the
# GATE-07 acceptance check greps for it literally — but qualified
# with "this status light's usual meaning is X; this trigger is not
# that one" so the body never asserts something false for the
# shape that actually fired. Fix guidance (element 4) is two lines
# naming the two files either way; only the target values differ.
case "$KIND" in
behind)
directional_note="未关闭即代表文档站落后于最新正式版。"
fix_line1="1. 把 \`.vitepress/config.mts\` 的 \`versionsConfig.current\` 改成 \`${CENTRAL_VER}\`"
fix_line2="2. 把 \`examples/pom.xml\` 的 \`<ultitools.version>\` 改成 \`${CENTRAL_VER}\`"
;;
mismatch)
directional_note="本状态灯的常规含义是:未关闭即代表文档站落后于最新正式版。本次触发的不是这一种——本仓库内部两处版本号不一致:\`.vitepress/config.mts\` 记录的是 \`${DOC_VER}\`,\`examples/pom.xml\` 记录的是 \`${POM_VER}\`。"
fix_line1="1. 把 \`.vitepress/config.mts\` 的 \`versionsConfig.current\`(当前 \`${DOC_VER}\`)与 \`examples/pom.xml\` 的 \`<ultitools.version>\`(当前 \`${POM_VER}\`)改成同一个值"
fix_line2="2. 对齐后再确认这个值与 Maven Central 的 \`${CENTRAL_VER}\` 一致"
;;
ahead)
directional_note="本状态灯的常规含义是:未关闭即代表文档站落后于最新正式版。本次触发的不是这一种——文档站版本号超前于 Maven Central 已收录的最新正式版 \`${CENTRAL_VER}\`。"
fix_line1="1. 确认这不是误改:如果是,把 \`.vitepress/config.mts\` 的 \`versionsConfig.current\` 改回 \`${CENTRAL_VER}\`"
fix_line2="2. 同时把 \`examples/pom.xml\` 的 \`<ultitools.version>\` 改回 \`${CENTRAL_VER}\`"
;;
esac
# D-05: link to the framework release automation's own `docs: sync
# v<central>` issue in this repo, if one exists. Optional body
# segment — query failure or a genuine miss both degrade to the
# same "not found" wording, never to a fail-loud step (this is a
# supplementary link, not the dedupe query Task 3 owns).
#
# `--limit 5`: this only ever consumes `.[0]`, and `gh issue list`'s
# default limit (30) is already far more than this exact-title
# search could plausibly ever match — the framework's release
# automation opens at most one `docs: sync v<X>` issue per release.
# Making the limit explicit here anyway (rather than relying on the
# default) documents that assumption instead of leaving it implicit,
# and keeps this call site consistent with the two dedupe queries
# below, which need an explicit limit for a real reason (they
# consume the full result, not just `.[0]`).
sync_issue_url=$(gh issue list --search "in:title \"docs: sync v${CENTRAL_VER}\"" \
--state all --limit 5 --json number,url --jq '.[0].url // empty') || sync_issue_url=""
if [ -n "$sync_issue_url" ]; then
sync_note="框架侧发布联动开出的同步 issue:${sync_issue_url}"
else
sync_note="未找到对应的同步 issue。框架仓库的发布联动在 \`DOCS_REPO_TOKEN\` 未配置时会静默跳过开 issue 这一步——那条 warning 只写进框架仓库自己的 Actions 日志,本仓库看不到。"
fi
# Written to a temp file and referenced with -F rather than passed
# inline, to avoid stacking YAML folding + bash quoting + Markdown
# backtick escaping three levels deep.
#
# The HTML comment line after the table is D-08's machine-readable
# anchor: a later run reads it back (Task 3) to decide whether the
# numbers actually changed, instead of parsing the Chinese prose
# above — the same reason D-13 rules out grepping this script's
# stdout. It renders invisibly to a human reading the issue.
cat > "$RUNNER_TEMP/release-sync-body.md" <<BODY
${directional_note}
| | |
|---|---|
| config.mts current | \`${DOC_VER}\` |
| examples/pom.xml | \`${POM_VER}\` |
| Maven Central | \`${CENTRAL_VER}\` |
<!-- release-sync-state: doc=${DOC_VER} pom=${POM_VER} central=${CENTRAL_VER} -->
失败 run:${RUN_URL}
${fix_line1}
${fix_line2}
${sync_note}
BODY
# D-02/D-03: dedupe scope is `--state open` only, dedupe key is the
# label only — not title, not the union of both (two parts that
# must always be kept in sync is exactly the shape Phase 1's D-01
# rejected for a different gate).
#
# This query is deliberately NOT `|| true`-guarded. Written as
# `if ! existing_json=$(...); then ... exit 1; fi` so "the query
# itself failed" and "there genuinely is no open status light" stay
# two distinguishable states. Swallowing the failure collapses both
# into the same empty string, and an empty string here means
# "nothing open yet" — so one transient network blip or auth
# failure would open a duplicate issue, which is exactly what
# D-02/D-03's dedupe exists to prevent.
#
# One query serves two purposes now (`number` for the D-02/D-03
# dedupe decision, `body` for D-08's diff below) rather than firing
# the request twice for one round of judgment.
#
# This is not the same class of failure as the `|| true` pipelines
# in check-version-consistency.sh (lines 19-22 there): that
# convention covers failures that are allowed and don't change the
# conclusion. This query's failure DOES change the next step's
# judgment (create vs. skip vs. edit), so it does not get the same
# treatment. D-05's link query above stays `|| true`-guarded — it's
# an optional body segment, not this decision.
#
# `--limit 100`: `gh issue list` defaults to `--limit 30`, and this
# query's result feeds `sort_by(.number) | .[0]` below — silently
# truncating at 30 would mean the "pick the oldest of N open
# release-sync issues" logic never sees issue #31 onward if more
# than 30 ever accumulate, the same silent-boundary-truncation
# shape as CR-01's `.[0]`-only bug, just at the pagination boundary
# instead of the first element. `release-sync` is a single
# repo-wide status light (D-01) — normal operation holds at most 1
# open, and even the worst realistic race (overlapping runs before
# the WR-01 concurrency guard existed, or a hand-opened duplicate)
# stays in the single digits. 100 is comfortably above any
# plausible count while still bounding the request, rather than
# requesting an unbounded number of issues on every run.
if ! existing_json=$(gh issue list --state open --label release-sync \
--limit 100 --json number,body); then
echo "::error::查询已有 release-sync 状态灯失败(网络或鉴权问题)。不吞掉这个失败,是为了不让它被误判为「没有已开状态灯」而新开一条重复 issue。"
exit 1
fi
# CR-01 修复:正常情况下应当只有 0 或 1 条打开的 release-sync issue,
# 但重叠的 workflow 运行(见下面 job 级 concurrency 块之前的历史行为)
# 或人工手开的第二条,都可能让这个数字变成 2 或更多。选哪一条作为本次
# 更新的目标必须是确定性的,不能依赖 gh 未文档化的默认排序——这里显式
# 按 issue number 升序排序后取最小的一条(即最早开出的那条)为canonical
# 目标。多出来的那些不在这一步处理:它们会在下面「Close if recovered」
# 那一步被全部关闭,而不是在这里被合并或编辑。
existing_count=$(printf '%s' "$existing_json" | jq -r 'length')
if [ "$existing_count" -gt 1 ]; then
echo "::warning::发现 ${existing_count} 条同时处于 open 状态的 release-sync 状态灯,预期最多 1 条。本步骤只更新其中编号最小(最早开出)的一条;其余的会在 recovered 时由下面的关闭步骤统一关闭,不在这里逐条处理。"
fi
# `// empty` on both jq filters matters: `.[0]` on an empty array
# evaluates to `null`, which gh's jq prints as the literal string
# `null`, and `[ -z "$existing" ]` would not read that as "nothing".
# `// empty` makes "nothing" a real empty string.
# `sort_by(.number)` makes the `.[0]` pick explicit and deterministic
# rather than relying on `gh issue list`'s incidental default order.
existing=$(printf '%s' "$existing_json" | jq -r 'sort_by(.number) | .[0].number // empty')
existing_body=$(printf '%s' "$existing_json" | jq -r 'sort_by(.number) | .[0].body // empty')
# D-08: read the PREVIOUS run's numbers back from the machine-
# readable state marker Task 2 writes (`doc=`/`pom=`/`central=`),
# never by parsing the Chinese prose above it — the same reason
# D-13 rules out grepping this script's stdout for the
# script→workflow leg. Fixed key names, not a positional/format
# guess.
#
# Extraction failure (e.g. a hand-opened `release-sync` issue with
# no marker — T-02-10's accepted case) leaves the three prev_*
# values empty, which can never equal a real version number — that
# steers into the "edit" branch below, the safe degradation
# direction: writing the body again costs nothing, but silently
# concluding "unchanged" when we genuinely can't tell would leave a
# stale/marker-less body sitting there indefinitely.
prev_doc=$(printf '%s' "$existing_body" | grep -oE 'doc=[0-9]+\.[0-9]+\.[0-9]+' | head -1 | cut -d= -f2) || prev_doc=""
prev_pom=$(printf '%s' "$existing_body" | grep -oE 'pom=[0-9]+\.[0-9]+\.[0-9]+' | head -1 | cut -d= -f2) || prev_pom=""
prev_central=$(printf '%s' "$existing_body" | grep -oE 'central=[0-9]+\.[0-9]+\.[0-9]+' | head -1 | cut -d= -f2) || prev_central=""
# D-01: fixed title, no version number — open/closed alone is the
# invariant's boolean. Three-way dispatch, no `continue-on-error`
# on any of them: this job is already red because the invariant
# broke, but "did the issue actually get opened/updated" still
# needs its own CI-level failure signal — the issue IS this job's
# output (D-07's reasoning, mirrored for the open side).
if [ -z "$existing" ]; then
gh issue create \
--title "docs: 文档版本与最新正式版不同步" \
--label release-sync \
-F "$RUNNER_TEMP/release-sync-body.md"
elif [ "$prev_doc" = "$DOC_VER" ] && [ "$prev_pom" = "$POM_VER" ] && [ "$prev_central" = "$CENTRAL_VER" ]; then
# D-08: numbers unchanged since the open issue's body was last
# written — do nothing at all: no state transition, no comment,
# no body rewrite. A year of "still broken" reruns must not turn
# into 52 comments (or 52 no-op body rewrites) on the same issue.
echo "状态灯已在(issue #$existing),版本号未变,跳过写操作。"
else
gh issue edit "$existing" -F "$RUNNER_TEMP/release-sync-body.md"
fi
- name: Close release-sync issue if recovered
# T-02-07 的不对称容错姿态:这一步与上面开 issue 那一步写法相同——都没有
# continue-on-error,两条查询命令都不用 `|| true` 兜底——但失败之后的
# 后果不对称。开 issue 那一步失败时,job 已经因为不变式破裂而红,那一步
# 再失败不改变结论;这一步一旦失败就会把 job 拖红,这正是 D-07 要的效果:
# 关闭失败不能悄悄留下一盏关不掉、但看起来和「真的还坏着」一模一样的
# 状态灯。
#
# 将来也不要在这里补 continue-on-error:框架侧 publish-packages.yml
# 上的那个开关保护的是「已经成功的发布」这个已经完成的成果;这个 job
# 的产出就是信号本身,关闭动作失败没有别的东西需要被保护。
#
# if: 事件面与开 issue 那一步完全一致(D-14),仍按事件名而非
# github.ref 卡,理由同 D-15——分支演练要在非 master 分支上 dispatch
# 到这里。
#
# G-02-12 修复:守卫不再是「job 级状态函数」(旧写法是 success())。
# 旧写法把这一步的关闭与 job 里**任何**一步的成败连在一起——索引步骤
# (id: index)就是第一个撞上这条耦合的例子:它在上游表单结构变化时
# exit 1,会让整个 job 变红,success() 因此为假,这一步被跳过,
# release-sync issue 因此继续挂着——而这条状态灯的正文写着「未关闭即
# 代表文档站落后于最新正式版」,此时那句话是假的:版本不变式明明已经
# 恢复,只是恰好在同一次运行里撞上了一个与它无关的索引失败。
# `Open or update javadoc-index issue` 的 `gh` 查询失败(那一步是有意
# fail-loud 的)会造成同样的连坐——索引步骤不是唯一能触发这条耦合的
# 例子,只是第一个被发现的。
#
# 新守卫改用 `steps.check.outputs.kind`(不变式检查步骤自身的输出)
# 判定这一件事是否发生了不变式已恢复:把它作为「已恢复」的信号会需要
# 额外论证「退出码为 0 时 kind 必定为空」这条隐含关系——`outputs.kind`
# 本来就只在 exit 1 时被脚本写入。这里直接使用
# `steps.check.outcome == 'success'`:`check` 这一步的契约就是
# 「exit 0 等于三个版本号相等」,`outcome` 直接就是那个脚本的退出结果,
# 不需要绕一层去论证与 kind 的关系。checkout 失败、`check` 被跳过、
# 或退出码非 0,四种情形下 `outcome` 都不等于 `success`,这条守卫在
# 那些情形下仍然是关闭的。
#
# `!cancelled()` 而不是隔壁 `Open or update javadoc-index issue` 用的
# 那个更宽的 `always()`:这一步会关 issue,是一次不可撤回的写操作,
# 在运行被取消的收尾阶段跑它没有意义——`always()` 连取消的运行也会让
# 它执行。隔壁那一步不改,是因为它没有本 gap 描述的缺陷,跟着改属于
# 本次修复范围之外的改动;两种写法在文件里并存,理由就写在这里,不
# 留给下一个读者去猜。
#
# D-32/D-34 的两盏独立状态灯不受影响:索引步骤自身的退出码分派、
# `javadoc-index` 状态灯的 broken/timeout/post-failed 分支、
# `Close javadoc-index issue if recovered`(它自己的 `success()` 守卫
# 与其 `index_result` 条件本就等价,不需要跟着改)全部原样保留——
# 本次改动只解耦了 release-sync 这一盏灯的关闭条件,索引出问题时仍然
# 由 `javadoc-index` 那盏灯表达,仍然把 job 拖红。
#
# D-14 的事件表达式三行原样不动。状态函数必须显式写出来:Actions 只
# 在 step 完全没有 if: 时才隐式附加 success(),一旦写了自定义 if:,
# 隐式附加就不再生效。
if: |
!cancelled() && steps.check.outcome == 'success' &&
(github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' ||
(github.event_name == 'push' && github.ref == 'refs/heads/master'))
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_REPO: ${{ github.repository }}
DOC_VER: ${{ steps.check.outputs.doc_ver }}
POM_VER: ${{ steps.check.outputs.pom_ver }}
CENTRAL_VER: ${{ steps.check.outputs.central_ver }}
run: |
# D-02/D-03: 查询姿态与开 issue 那一步同一个姿态——不用 `|| true`
# 兜底。一次查询失败如果被吞掉,existing_numbers 会变成空,代码就会
# 当作「没有状态灯要关」而静静走完,job 保持绿色,而状态灯还亮着——
# 外观与「真的还坏着」一模一样。这正是 D-07 要除掉的失败形状。
#
# CR-01 修复:这里改成取全部(`.[]`)而不是只取 `.[0]`。正常情况下
# open 的 release-sync issue 最多只有一条,但重叠的 workflow 运行、
# 或人工手开的重复 issue,都可能让这个数字变成 2 条以上——旧写法
# 只关掉排第一的那条,其余的会被永远遗漏在 open 状态,让「未关闭即
# 代表文档站落后于最新正式版」这句话变成假话。这一步存在的唯一
# 目的就是保证「恢复」等价于「关闭全部」,所以必须逐条处理,不能
# 只挑一条。
#
# `--limit 100`:`gh issue list` 默认只取 30 条,而这里取的是全部
# 结果集,不是 `.[0]`——默认值会在这里静默截断,是和上面
# `.[0]`-only 那个 bug 同一种失败形状,只是发生在分页边界而不是第
# 一个元素上。`release-sync` 是单一的仓库级状态灯(D-01),正常
# 情况下最多一条 open,即使遇到最坏的现实场景(WR-01 并发保护上线
# 前的重叠运行、或人工手开的重复 issue)也停留在个位数。100 已经
# 远高于任何现实场景会出现的数量,同时仍然是一个显式的上限,而不
# 是让每次运行都发出一个无上限的请求。
if ! existing_numbers=$(gh issue list --state open --label release-sync \
--limit 100 --json number --jq '.[].number'); then
echo "::error::查询已有 release-sync 状态灯失败(网络或鉴权问题)。不吞掉这个失败,是为了不让本来绿的 job 悄悄保持绿色,而状态灯其实还没被关掉。"
exit 1
fi
# existing_numbers 为空是最常见的情况——绝大多数绿色运行都没有状态灯
# 要关,这不是错误。非空时逐条关闭:`gh issue close` 自带 -c/--comment
# 参数,一次性完成「追一条已恢复评论」加「关闭」两个动作
# (RESEARCH Pattern 2),不需要先单独发一条评论再关闭。多条时每条
# 都各自收到同一句「已恢复」评论并各自关闭,不合并成一条评论。
if [ -n "$existing_numbers" ]; then
while IFS= read -r existing; do
gh issue close "$existing" -c "版本已恢复同步:\`${DOC_VER}\` == \`${POM_VER}\` == \`${CENTRAL_VER}\`。"
done <<< "$existing_numbers"
fi
version-signal-regression-tests:
name: check-version-consistency.sh regression tests
runs-on: ubuntu-latest
# WR-05 fix: scripts/test-version-signal.sh guards the two silent-
# wrong-answer bugs this Phase exists to avoid (the two-digit-PATCH
# lexicographic-compare bug in F_two_digit_patch, and the mismatch-
# before-direction judgment order in G_mismatch) — until now nothing
# ran it, so a regression on either bug would ship silently. It needs
# no GitHub API access (every case strips `gh` from PATH so the script
# always takes its early-return branch), so no `permissions` beyond
# the job-default read-only scope is required.
#
# Excluded from `pull_request`: 4 of its 7 cases probe Maven Central
# over the network, and the harness itself is written to fail (not
# silently skip) when that network is unreachable — a deliberate
# choice so an unreachable-network environment doesn't quietly stop
# providing coverage forever. Running that as a required `pull_request`
# check would fail unrelated PRs on a transient network blip that has
# nothing to do with the PR's own content. `push` triggers on every
# branch (`branches: ["**"]` above), so a same-repo PR's source branch
# still gets this coverage via its own push events; a fork PR's branch
# never produces a push event against this repository and so misses
# coverage until it lands on a branch here — an accepted, documented
# gap (see WR-05, .planning/phases/02-version-sync-signal/02-REVIEW.md),
# not a silent one.
if: github.event_name != 'pull_request'
permissions:
contents: read
steps:
- name: Checkout
uses: actions/checkout@v4
with:
# 构建期会执行仓库外的代码,不持久化凭据可避免 token 落到 .git/config
persist-credentials: false
- name: Run check-version-consistency.sh regression tests
run: bash scripts/test-version-signal.sh
javadoc-index-assertions:
name: javadoc-io-index.sh structural assertions
runs-on: ubuntu-latest
# 这些 fixture 从 Phase 02 起就在仓库里,但在此之前没有任何东西跑它们:
# 本工作流只在发版路径上调用 javadoc-io-index.sh 的真实运行,fixture 只在
# 人工排查时被手动喂进 --check-forms-only。断言因此可以写松而无人发现——
# 三处出口(upload 表单不校验 method、版本号被拼进 ERE 让点号成通配符、
# csrfToken 只从 upload 表单抽)一直开着,直到 review 才被看见。
#
# 与同文件的 version-signal-regression-tests 不同,本作业不碰网络:
# --check-forms-only 配合 --page-file 完全离线,不触碰上游状态。因此它可以
# 在 pull_request 上跑,而不必像那一条那样排除 PR 事件。
steps:
- name: Checkout
uses: actions/checkout@v4
with:
# 构建期会执行仓库外的代码,不持久化凭据可避免 token 落到 .git/config
persist-credentials: false
- name: Run javadoc-io-index.sh fixture assertions
run: bash scripts/check-javadoc-index-assertions.sh