-
-
Notifications
You must be signed in to change notification settings - Fork 8
934 lines (858 loc) · 57.2 KB
/
Copy pathrelease-script-check.yml
File metadata and controls
934 lines (858 loc) · 57.2 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
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
name: Release Script Check
# Guards scripts/cut-release.ps1. The main CI path filters skip build/release-script
# changes, so without this a break in the release tooling (a parse error, a bad
# parameter set, a throw in the pre-flight / version bump / metadata gate) would ship
# unnoticed and only surface mid-release. This runs the script's dry-runs — which
# exercise every code path except the actual git/Maven mutations — plus focused unit
# checks of the helpers a dry-run cannot reach on its own: the version arithmetic, the
# README install-snippet bump, the README and roadmap promotions, the japicmp baseline
# move, and the knowledge-tooling refusal. It creates no tag and touches no remote.
on:
push:
paths:
- 'scripts/cut-release.ps1'
- 'scripts/release-notes.mjs'
- 'scripts/release-notes.test.mjs'
- '.github/workflows/release.yml'
- '.github/workflows/release-script-check.yml'
# Carries the japicmp previous-release pin the script moves.
- 'templates/pom.xml'
# The published page is generated, so the cut bumps web-src/data/release.json and runs
# the site build. A change to either is a change to the release path.
- 'scripts/site/**'
- 'web-src/**'
pull_request:
paths:
- 'scripts/cut-release.ps1'
- 'scripts/release-notes.mjs'
- 'scripts/release-notes.test.mjs'
- '.github/workflows/release.yml'
- '.github/workflows/release-script-check.yml'
- 'templates/pom.xml'
- 'scripts/site/**'
- 'web-src/**'
permissions:
contents: read
jobs:
dry-run:
name: cut-release.ps1 dry-run
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- name: Release notes fit a GitHub Release body
run: |
# release.yml publishes the tag's CHANGELOG section through this script, and
# GitHub refuses a body over 125 000 characters. The oversized case is the
# real 2.4.0 section, the one that failed its tag.
node scripts/release-notes.test.mjs
- name: Resolve a throwaway version on the roadmap's current line
shell: pwsh
run: |
# The cut refuses a version whose release line the ROADMAP 'Current stable'
# section does not describe — a minor bump needs a section a maintainer wrote,
# not a one-token rewrite. So the rehearsal version has to sit on whatever line
# the roadmap currently carries. Derived rather than hardcoded: pinning it
# would break this job the day the project moves to the next line, which is
# exactly when the release path most needs a working rehearsal.
$heading = Select-String -Path ROADMAP.md -Pattern '##\s+Current stable\s*[—-]\s*(\d+\.\d+)'
if (-not $heading) { throw "ROADMAP.md has no '## Current stable — X.Y' heading to derive a rehearsal version from" }
$line = $heading.Matches[0].Groups[1].Value
"REHEARSAL_VERSION=$line.99" >> $env:GITHUB_ENV
Write-Host "rehearsing a cut at $line.99 (roadmap line $line)"
- name: Dry-run a full release cut
shell: pwsh
run: |
# -DryRun mutates nothing; -SkipShowcase skips the mvnw install the showcase
# regen would run. A parse error, a bad parameter set, or any throw fails the
# step. The version is a throwaway — nothing is written or pushed.
./scripts/cut-release.ps1 -Version $env:REHEARSAL_VERSION -Branch develop -DryRun -SkipShowcase
if ($LASTEXITCODE -ne 0) { throw "full-cut dry-run exited $LASTEXITCODE" }
- name: A cut across release lines is refused until the section is prepared
shell: pwsh
run: |
# The negative half, and the failure it guards: rewriting only the bolded
# version would leave '## Current stable — 2.1' above '**2.2.0** is the current
# release'. Both the old guard and the new one pass that, because each asks
# only whether the version is published. The cut has to refuse it instead.
#
# The line to try is the one after everything the roadmap names, not the one
# after 'Current stable'. Staging the next line is what makes a crossing cut
# legal — so once a maintainer prepares it, the version derived from the
# current-stable heading alone is a cut the script is right to allow, and this
# step fails for the roadmap being ready.
$content = Get-Content ROADMAP.md -Raw
$named = [regex]::Matches($content, '##\s+(?:Current stable|Upcoming)\s*[—-]\s*(\d+)\.(\d+)')
if ($named.Count -eq 0) { throw "ROADMAP.md names no release line to derive an unprepared one from" }
$line = (Select-String -Path ROADMAP.md -Pattern '##\s+Current stable\s*[—-]\s*(\d+\.\d+)').Matches[0].Groups[1].Value
$highest = $named |
ForEach-Object { [pscustomobject]@{ Major = [int]$_.Groups[1].Value; Minor = [int]$_.Groups[2].Value } } |
Sort-Object Major, Minor |
Select-Object -Last 1
$crossing = "$($highest.Major).$($highest.Minor + 1).0"
Write-Host "roadmap names up to $($highest.Major).$($highest.Minor); trying a cut at $crossing"
# The refusal is a `throw`, which terminates the script — so it has to be
# caught here, not merged with `*>&1`. Redirection captures the message but
# not the termination, and an uncaught throw fails this step for doing
# precisely what it is being tested for.
$out = ''
try {
$out = ./scripts/cut-release.ps1 -Version $crossing -Branch develop -DryRun -SkipShowcase *>&1 | Out-String
} catch {
$out = ($_ | Out-String)
}
$global:LASTEXITCODE = 0
# PowerShell wraps a thrown message across lines and prefixes each with '| ',
# so a phrase long enough to be worth asserting is split before it can be
# matched. Flatten the continuation markers and the wrapping first.
$flat = (($out -replace '\r?\n\s*\|\s*', ' ') -replace '\s+', ' ')
if ($flat -notmatch 'describes the .* line, but this cut is') {
throw "a cut at $crossing must refuse the $line section, but the script did not say so. Output:`n$out"
}
# And it must refuse before writing anything. The check used to live beside the
# edit in Step 1, by which point every pom and README already carried the new
# version — so the refusal handed back a dirty tree for a condition that was
# knowable before the first write.
$dirty = git status --porcelain
if ($dirty) {
throw "the refusal left the tree dirty, so it ran after some mutation:`n$dirty"
}
Write-Host "refused $crossing against the $line section, before touching anything"
- name: Dry-run PostReleaseOnly
shell: pwsh
run: |
./scripts/cut-release.ps1 -PostReleaseOnly -Branch develop -DryRun
if ($LASTEXITCODE -ne 0) { throw "PostReleaseOnly dry-run exited $LASTEXITCODE" }
- name: The asset version follows the tag, and only a final tag
shell: pwsh
run: |
# <graphcompose.examples.assetVersion> records the version the committed previews
# were rendered at, and ExampleVersion accepts a released X.Y.Z and nothing else.
# So it must move on a final cut and stay put everywhere else: carried into a
# -SNAPSHOT by the post-release bump it would throw before a single preview was
# compared, and carried into an -rc it would have the previews advertise a release
# that does not exist. Assert the branch that ran, not the file it would write:
# -DryRun mutates nothing, so the notice is the only evidence either way.
$version = $env:REHEARSAL_VERSION
$final = ./scripts/cut-release.ps1 -Version $version -Branch develop -DryRun -SkipShowcase *>&1 | Out-String
if ($LASTEXITCODE -ne 0) { throw "final dry-run exited $LASTEXITCODE" }
if ($final -notmatch "asset version -> $([regex]::Escape($version))") { throw 'a final cut must move the asset version' }
if ($final -notmatch 'Re-render the README assets') { throw 'a final cut must re-render the previews' }
$rc = ./scripts/cut-release.ps1 -Version 2.1.0-rc.1 -Branch develop -DryRun -SkipShowcase *>&1 | Out-String
if ($rc -match 'asset version ->') { throw 'a pre-release cut must NOT move the asset version' }
if ($rc -notmatch 'Skipped the README assets') { throw 'a pre-release cut must leave the previews alone' }
$post = ./scripts/cut-release.ps1 -PostReleaseOnly -Branch develop -DryRun *>&1 | Out-String
if ($post -match 'asset version ->') { throw 'the post-release bump must NOT move the asset version' }
Write-Host 'asset version: moves on a final cut, stays put on a pre-release and post-release.'
- name: Dry-run a pre-release (RC) cut
shell: pwsh
run: |
# A pre-release (X.Y.Z-rc.N) ships only to the GitHub Release pre-release surface,
# never to Maven Central, so the cut must NOT require 'Latest stable' or rewrite the
# Central install snippets. Capture ALL streams (*>&1) — the notices are Write-Host.
$out = ./scripts/cut-release.ps1 -Version 2.1.0-rc.1 -Branch develop -DryRun -SkipShowcase *>&1 | Out-String
if ($LASTEXITCODE -ne 0) { throw "pre-release dry-run exited $LASTEXITCODE" }
if ($out -notmatch 'pre-release \(2\.1\.0-rc\.1\)') { throw "expected the pre-release notice" }
if ($out -notmatch 'skipped .* install-snippet bumps') { throw "expected the snippet-skip notice" }
if ($out -match 'bumped README Maven Central snippet') { throw "pre-release must NOT bump the README install snippet" }
if ($out -match "Latest stable.*does not name v2\.1\.0-rc\.1") { throw "pre-release must NOT check 'Latest stable'" }
Write-Host "pre-release path: Latest-stable + Central snippets correctly left on last stable."
# An unsupported version (arbitrary suffix) must be REJECTED, not silently
# treated as a pre-release. The script rejects via `throw` — a TERMINATING
# error — which `*>&1 | Out-String` does NOT trap when the script runs
# in-process (dot-invocation, as here); it must be caught with try/catch, and
# $LASTEXITCODE is not set by a throw so it is not a usable signal.
$bad = try { ./scripts/cut-release.ps1 -Version 2.1.0-preview.1 -Branch develop -DryRun -SkipShowcase *>&1 | Out-String }
catch { $_ | Out-String }
if ($bad -notmatch 'Unsupported version') { throw "expected the unsupported-version error, got: $bad" }
Write-Host "unsupported version 2.1.0-preview.1: correctly rejected."
- name: Unit-check Get-NextSnapshotVersion
shell: pwsh
run: |
# Extract the pure helper (from its declaration to the first column-0 '}')
# and load it in isolation, then assert the version arithmetic.
$lines = Get-Content scripts/cut-release.ps1
$match = $lines | Select-String -Pattern '^function Get-NextSnapshotVersion' | Select-Object -First 1
if (-not $match) { throw "Get-NextSnapshotVersion not found" }
$start = $match.LineNumber - 1
$end = -1
for ($i = $start + 1; $i -lt $lines.Count; $i++) { if ($lines[$i] -eq '}') { $end = $i; break } }
if ($end -lt 0) { throw "could not find the function's closing brace" }
Invoke-Expression (($lines[$start..$end]) -join "`n")
$cases = @{ '2.0.0' = '2.0.1-SNAPSHOT'; '2.3.9' = '2.3.10-SNAPSHOT'; '1.9.1' = '1.9.2-SNAPSHOT';
'2.0.0-rc.1' = $null; '2.0.1-SNAPSHOT' = $null }
foreach ($k in $cases.Keys) {
$got = Get-NextSnapshotVersion $k
if ($got -ne $cases[$k]) { throw "Get-NextSnapshotVersion '$k' -> '$got', expected '$($cases[$k])'" }
}
Write-Host "Get-NextSnapshotVersion: all cases passed."
- name: Unit-check Test-ReadmeLatestStable (real README)
shell: pwsh
run: |
# Load the real stale-tag-README guard and run it against the ACTUAL README —
# exercises its regex on live content, not just the dry-run's print. Robust
# across releases: assert it matches the version the README currently names,
# and rejects a bogus one.
$repoRoot = (Get-Location).Path
$lines = Get-Content scripts/cut-release.ps1
$match = $lines | Select-String -Pattern '^function Test-ReadmeLatestStable' | Select-Object -First 1
if (-not $match) { throw "Test-ReadmeLatestStable not found" }
$start = $match.LineNumber - 1
$end = -1
for ($i = $start + 1; $i -lt $lines.Count; $i++) { if ($lines[$i] -eq '}') { $end = $i; break } }
if ($end -lt 0) { throw "could not find the function's closing brace" }
Invoke-Expression (($lines[$start..$end]) -join "`n")
$cur = [regex]::Match((Get-Content README.md -Raw), '\*\*Latest stable\*\*:\s*\[v([\w\.\-]+)\]').Groups[1].Value
if (-not $cur) { throw "could not read the README 'Latest stable' version" }
if (-not (Test-ReadmeLatestStable $cur)) { throw "Test-ReadmeLatestStable failed for the README's own version $cur" }
if (Test-ReadmeLatestStable '0.0.0-nonexistent') { throw "Test-ReadmeLatestStable matched a bogus version" }
Write-Host "Test-ReadmeLatestStable: README names v$cur (bogus rejected)."
- name: Unit-check the README release-status promotion
shell: pwsh
run: |
# The block is two halves, and what moves between them is the sentence, not
# only the version. Lifted by AST like the roadmap check below, so the code
# under test is the code that ships.
$path = (Resolve-Path scripts/cut-release.ps1).Path
$ast = [System.Management.Automation.Language.Parser]::ParseFile($path, [ref]$null, [ref]$null)
$want = @('Update-ReadmeReleaseStatus', 'Move-ReleaseStatusProse', 'Get-NextSnapshotVersion')
$found = $ast.FindAll({
param($n)
$n -is [System.Management.Automation.Language.FunctionDefinitionAst] -and $want -contains $n.Name
}, $true)
$missing = $want | Where-Object { $_ -notin ($found | ForEach-Object { $_.Name }) }
if ($missing) { throw "cut-release.ps1 no longer defines: $($missing -join ', ')" }
Invoke-Expression (($found | ForEach-Object { $_.Extent.Text }) -join "`n")
function Note($m) { Write-Host " $m" }
$DryRun = $false
function New-Readme($stableProse, $devProse) {
$file = Join-Path ([IO.Path]::GetTempPath()) ("readme-" + [guid]::NewGuid().ToString() + ".md")
Set-Content -Path $file -NoNewline -Value (@(
'> **Release status** —',
"> 🟢 **Latest stable**: [v1.2.3](https://github.com/DemchaAV/GraphCompose/releases/tag/v1.2.3) — $stableProse",
"> · 🟡 **In development**: v1.3.0 on ``develop`` — $devProse"
) -join "`n")
return $file
}
# 1. A staged story is the release's own: it moves up, and the lower half is
# left with the pointer. Leaving it behind would publish the new version
# under the previous line's headline.
$readme = New-Readme 'the **previous** release: what 1.2 was about.' 'the **staged** release: what 1.3 is about.'
Update-ReadmeReleaseStatus $readme '1.3.0'
$after = Get-Content $readme -Raw
if ($after -notmatch '\*\*Latest stable\*\*:\s*\[v1\.3\.0\]\(\S+/releases/tag/v1\.3\.0\) — the \*\*staged\*\* release') {
throw "the staged story did not move onto the latest-stable half:`n$after"
}
if ($after -match 'the \*\*previous\*\* release') { throw "the superseded headline survived the promotion:`n$after" }
if ($after -notmatch '\*\*In development\*\*: v1\.3\.1 on `develop` — see \[CHANGELOG\.md\]') {
throw "the lower half was not opened on the next patch with the changelog pointer:`n$after"
}
# 2. Nothing staged — a patch on the same line has no new story, and its
# headline is the one already sitting above. Promoting the pointer would
# leave the release advertised by nothing.
$readme = New-Readme 'the **line** release: what 1.3 was about.' 'see [CHANGELOG.md](./CHANGELOG.md).'
Update-ReadmeReleaseStatus $readme '1.3.1'
$after = Get-Content $readme -Raw
if ($after -notmatch '\*\*Latest stable\*\*:\s*\[v1\.3\.1\].*the \*\*line\*\* release') {
throw "a patch cut lost the headline of the line it belongs to:`n$after"
}
Write-Host "release-status: a staged story is promoted, a patch keeps its line's headline."
- name: Unit-check the README install-snippet bump
shell: pwsh
run: |
# Every train coordinate the root README names moves with a final cut, not only
# graph-compose: v2.4.0 shipped graph-compose-testing:2.3.0 beside
# graph-compose:2.4.0 because the bump matched the bare coordinate alone. The
# companions version on their own tags and must stay put. Lifted by AST like the
# checks around it, so the code under test is the code that ships.
$path = (Resolve-Path scripts/cut-release.ps1).Path
$ast = [System.Management.Automation.Language.Parser]::ParseFile($path, [ref]$null, [ref]$null)
$fn = $ast.FindAll({
param($n)
$n -is [System.Management.Automation.Language.FunctionDefinitionAst] -and $n.Name -eq 'Update-ReadmeInstallVersion'
}, $true) | Select-Object -First 1
if (-not $fn) { throw 'cut-release.ps1 no longer defines Update-ReadmeInstallVersion' }
$calls = $ast.FindAll({
param($n)
$n -is [System.Management.Automation.Language.CommandAst] -and $n.GetCommandName() -eq 'Update-ReadmeInstallVersion'
}, $true)
if ($calls.Count -eq 0) { throw 'nothing in cut-release.ps1 calls Update-ReadmeInstallVersion' }
Invoke-Expression $fn.Extent.Text
function Note($m) { Write-Host " $m" }
$DryRun = $false
# 1. A fixture carrying every shape, against its exact expected text. The test
# coordinate starts a release behind, as it did on the README that shipped.
$fixture = Join-Path ([IO.Path]::GetTempPath()) ("readme-" + [guid]::NewGuid().ToString() + ".md")
Set-Content -Path $fixture -NoNewline -Value (@(
' <artifactId>graph-compose</artifactId>',
' <version>1.2.3</version>',
'dependencies { implementation("io.github.demchaav:graph-compose:1.2.3") }',
' <artifactId>graph-compose-testing</artifactId>',
' <version>1.2.2</version>',
'dependencies { testImplementation("io.github.demchaav:graph-compose-testing:1.2.2") }',
' <artifactId>graph-compose-fonts</artifactId>',
' <version>1.1.0</version>',
'dependencies { implementation("io.github.demchaav:graph-compose-emoji:1.0.0") }'
) -join "`n")
$expected = @(
' <artifactId>graph-compose</artifactId>',
' <version>9.9.9</version>',
'dependencies { implementation("io.github.demchaav:graph-compose:9.9.9") }',
' <artifactId>graph-compose-testing</artifactId>',
' <version>9.9.9</version>',
'dependencies { testImplementation("io.github.demchaav:graph-compose-testing:9.9.9") }',
' <artifactId>graph-compose-fonts</artifactId>',
' <version>1.1.0</version>',
'dependencies { implementation("io.github.demchaav:graph-compose-emoji:1.0.0") }'
) -join "`n"
Update-ReadmeInstallVersion $fixture '9.9.9'
$after = Get-Content $fixture -Raw
if ($after -ne $expected) { throw "the fixture did not bump to its expected text:`n$after" }
Update-ReadmeInstallVersion $fixture '9.9.9'
if ((Get-Content $fixture -Raw) -ne $expected) { throw 'a second bump changed the fixture' }
# 2. The real README: nothing left behind, and nothing but version tokens moved.
$copy = Join-Path ([IO.Path]::GetTempPath()) ("readme-real-" + [guid]::NewGuid().ToString() + ".md")
Copy-Item README.md $copy
Update-ReadmeInstallVersion $copy '9.9.9'
$old = @(Get-Content README.md)
$new = @(Get-Content $copy)
if ($old.Count -ne $new.Count) { throw 'the bump changed the README line count' }
$companions = @('graph-compose-fonts', 'graph-compose-emoji')
$checked = 0
for ($i = 0; $i -lt $new.Count; $i++) {
if ($old[$i] -ne $new[$i] -and $new[$i] -notmatch '9\.9\.9') {
throw "README line $($i + 1) changed without taking the new version: $($new[$i])"
}
if ($new[$i] -match '<artifactId>(graph-compose[\w-]*)</artifactId>') {
$artifact = $Matches[1]
if ($artifact -notin $companions) {
$window = $new[$i..([Math]::Min($i + 3, $new.Count - 1))] -join "`n"
$version = [regex]::Match($window, '<version>([^<]+)</version>').Groups[1].Value
if ($version -ne '9.9.9') { throw "README $artifact Maven snippet reads '$version' after the bump" }
$checked++
}
}
foreach ($gradle in [regex]::Matches($new[$i], 'io\.github\.demchaav:(graph-compose[\w-]*):([^"'')\s]+)')) {
if ($gradle.Groups[1].Value -in $companions) { continue }
if ($gradle.Groups[2].Value -ne '9.9.9') {
throw "README $($gradle.Groups[1].Value) Gradle snippet reads '$($gradle.Groups[2].Value)' after the bump"
}
$checked++
}
}
if ($checked -lt 2) { throw "the README should carry at least the graph-compose Maven and Gradle snippets; checked $checked" }
Write-Host "README install snippets: every train coordinate moves, companions stay, idempotent; real README $checked coordinates checked."
- name: Unit-check the site release-data bump
shell: pwsh
run: |
# web/index.html is generated from web-src/, so the cut no longer rewrites the page —
# it moves the two values the build injects into the seven version spots, and the
# site is rebuilt from them. Three failures this covers: a value left behind, a
# non-version field dragged along by a sloppy pattern, and a spot that quietly stops
# matching — renamed or reformatted — after which the function used to report success
# while publishing the previous version. Lifted by AST like the checks around it, so
# the code under test is the code that ships.
$path = (Resolve-Path scripts/cut-release.ps1).Path
$ast = [System.Management.Automation.Language.Parser]::ParseFile($path, [ref]$null, [ref]$null)
$fn = $ast.FindAll({
param($n)
$n -is [System.Management.Automation.Language.FunctionDefinitionAst] -and $n.Name -eq 'Update-SiteReleaseData'
}, $true) | Select-Object -First 1
if (-not $fn) { throw 'cut-release.ps1 no longer defines Update-SiteReleaseData' }
$calls = $ast.FindAll({
param($n)
$n -is [System.Management.Automation.Language.CommandAst] -and $n.GetCommandName() -eq 'Update-SiteReleaseData'
}, $true)
if ($calls.Count -eq 0) { throw 'nothing in cut-release.ps1 calls Update-SiteReleaseData' }
Invoke-Expression $fn.Extent.Text
function Note($m) { Write-Host " $m" }
$DryRun = $false
# One literal with placeholders, not a multi-line array of concatenations. The version
# has to land *inside* the JSON string, and a builder whose pieces can come apart
# writes "stableVersion": "<newline>1.2.3<newline>" — on which the pattern correctly
# finds nothing and this step fails for its own fixture instead of for the code.
function New-ReleaseData([string] $version) {
$file = Join-Path ([IO.Path]::GetTempPath()) ("release-" + [guid]::NewGuid().ToString() + ".json")
$json = '{~ "stableVersion": "VER",~ "releaseTag": "vVER",~ "javaMinimum": "17"~}'
Set-Content -Path $file -NoNewline -Value ($json.Replace('VER', $version).Replace('~', [string][char]10))
return $file
}
# 1. Both values move, and the bare/prefixed split is kept: the Maven Central
# coordinates the build writes take the bare semver, the badge and tag the v-form.
$data = New-ReleaseData '1.2.3'
Update-SiteReleaseData $data '9.9.9'
$after = Get-Content $data -Raw
if ($after -match '1\.2\.3') { throw "a 1.2.3 version survived the bump:`n$after" }
if ($after -notmatch '"stableVersion": "9\.9\.9"') { throw "stableVersion did not move:`n$after" }
if ($after -notmatch '"releaseTag": "v9\.9\.9"') { throw "the release tag kept no v-prefix:`n$after" }
# javaMinimum is not a release version and must not be dragged along — the failure a
# pattern loose enough to match any quoted value would produce.
if ($after -notmatch '"javaMinimum": "17"') { throw "the bump moved a field that is not the version:`n$after" }
# 2. Idempotent: a second bump is a no-op, not a throw.
Update-SiteReleaseData $data '9.9.9'
if ((Get-Content $data -Raw) -ne $after) { throw 'a second bump changed the file' }
# 3. A spot that no longer matches stops the cut, and names itself.
$moved = (Get-Content $data -Raw) -replace '"stableVersion"', '"stable_version"'
$broken = Join-Path ([IO.Path]::GetTempPath()) ("release-broken-" + [guid]::NewGuid().ToString() + ".json")
Set-Content -Path $broken -NoNewline -Value $moved
$refusal = try { Update-SiteReleaseData $broken '8.8.8'; '' } catch { "$_" }
if ($refusal -notmatch 'stableVersion') {
throw "a vanished spot did not stop the cut by name, got: '$refusal'"
}
if ((Get-Content $broken -Raw) -match '8\.8\.8') { throw 'the refusal still rewrote the file' }
Write-Host 'site release data: both values move, non-version fields stay, idempotent, and a vanished spot refuses.'
- name: The published page is rebuilt from the bumped data, not edited
shell: pwsh
run: |
# The half the unit check above cannot see: moving the data is only half a release,
# because what a visitor and a crawler read is the generated page. Run the real build
# against a bumped copy of the release data and assert the version reaches every spot
# VersionConsistencyGuardTest holds — then restore, so the job leaves no diff.
$data = 'web-src/data/release.json'
$backup = Get-Content $data -Raw
try {
Set-Content -Path $data -NoNewline -Value ($backup -replace '"stableVersion": "[^"]+"', '"stableVersion": "9.9.9"' -replace '"releaseTag": "[^"]+"', '"releaseTag": "v9.9.9"')
node scripts/site/build.mjs
if ($LASTEXITCODE -ne 0) { throw 'the site build failed on bumped release data' }
$page = Get-Content web/index.html -Raw
foreach ($shape in @(
'"stableVersion": "9\.9\.9"',
'"releaseTag": "v9\.9\.9"',
'"softwareVersion": "9\.9\.9"',
'graph-compose/9\.9\.9',
'v9\.9\.9 · MIT',
'<version>9\.9\.9</version>',
"io\.github\.demchaav:graph-compose:9\.9\.9")) {
if ($page -notmatch $shape) { throw "the rebuilt page does not carry /$shape/" }
}
Write-Host 'the bumped data reaches all seven version spots of the generated page.'
} finally {
# Restore the input AND rebuild from it. The try block wrote a 9.9.9 page, so checking
# here without rebuilding compares that page against a 2.4.0 build and fails every
# run — for this step's own fixture rather than for the code under test.
Set-Content -Path $data -NoNewline -Value $backup
node scripts/site/build.mjs
if ($LASTEXITCODE -ne 0) { throw 'the site build failed while restoring the tree' }
node scripts/site/build.mjs --check
if ($LASTEXITCODE -ne 0) { throw 'the tree was not restored after the rebuild check' }
}
- name: The cut stages every generated page a rebuild changes, and nothing under showcase
shell: pwsh
run: |
# The release commit carries the generated pages — the documentation page and a page per
# card — through glob pathspecs. A pathspec one level off would tag a release without the
# pages its rebuild wrote, and one reaching web/showcase/ would commit the /ID churn the
# cut restores; Step 5's guards read the working tree, so neither would stop the cut.
# Lifted by AST, then run against this checkout: the documentation page and a card page
# rewritten, a card page deleted, one added, a catalogue file touched, and an index.html
# planted under web/showcase/ at the depth of a card page, where the card-page glob alone
# would reach it — `git add --dry-run` has to name the four pages and nothing else.
$path = (Resolve-Path scripts/cut-release.ps1).Path
$ast = [System.Management.Automation.Language.Parser]::ParseFile($path, [ref]$null, [ref]$null)
$fn = $ast.FindAll({
param($n)
$n -is [System.Management.Automation.Language.FunctionDefinitionAst] -and $n.Name -eq 'Get-GeneratedPagePathspecs'
}, $true) | Select-Object -First 1
if (-not $fn) { throw 'cut-release.ps1 no longer defines Get-GeneratedPagePathspecs' }
$calls = $ast.FindAll({
param($n)
$n -is [System.Management.Automation.Language.CommandAst] -and $n.GetCommandName() -eq 'Get-GeneratedPagePathspecs'
}, $true)
if ($calls.Count -lt 2) {
throw "the release commit and the post-release commit both stage the pages; found $($calls.Count) call(s)"
}
Invoke-Expression $fn.Extent.Text
$repoRoot = (Get-Location).Path
$pages = @(git ls-files -- ':(glob)web/*/*/*/index.html')
if ($pages.Count -lt 2) { throw "expected committed document pages under web/, found $($pages.Count)" }
$documentation = 'web/documentation/index.html'
if (-not (git ls-files -- $documentation)) { throw "expected a committed $documentation" }
$rewritten = $pages[0]
$deleted = $pages[1]
$added = 'web/probe-category/probe-family/probe-card/index.html'
$planted = 'web/showcase/probe-a/probe-b/index.html'
$catalogueFile = @(git ls-files -- web/showcase)[0]
try {
Add-Content -Path $documentation -Value '<!-- rewritten -->'
Add-Content -Path $rewritten -Value '<!-- rewritten -->'
Remove-Item $deleted
New-Item -ItemType Directory -Force -Path (Split-Path $added) | Out-Null
Set-Content -Path $added -Value '<!-- added -->'
New-Item -ItemType Directory -Force -Path (Split-Path $planted) | Out-Null
Set-Content -Path $planted -Value '<!-- not a page -->'
Add-Content -Path $catalogueFile -Value 'touched'
$staged = @(git add --dry-run -- @(Get-GeneratedPagePathspecs)) | Sort-Object
if ($LASTEXITCODE -ne 0) { throw 'git add --dry-run refused the page pathspecs' }
$expected = @("add '$documentation'", "add '$added'", "add '$rewritten'", "remove '$deleted'") | Sort-Object
if (($staged -join "`n") -ne ($expected -join "`n")) {
throw "the page pathspecs stage:`n$($staged -join "`n")`nwhere a rebuild changed:`n$($expected -join "`n")"
}
} finally {
git checkout -- $documentation $rewritten $deleted $catalogueFile
Remove-Item -Recurse -Force web/probe-category -ErrorAction SilentlyContinue
Remove-Item -Recurse -Force web/showcase/probe-a -ErrorAction SilentlyContinue
}
if (git status --porcelain -- web) { throw 'the probe left the web/ tree changed' }
Write-Host 'page staging: the documentation page and a rewritten, a deleted and an added card page are staged, and nothing under web/showcase/.'
- name: The cut stages the showcase assets its own regeneration rewrote
shell: pwsh
run: |
# The step above proves the page globs are right on their own - which they were
# throughout. This one proves the list they used to stand in stages what it names.
# Get-ReleaseCommitPathspecs builds every path of the release commit bar the pages, and
# goes to one `git add`. The page globs carry :(exclude)web/showcase, and git applies an
# exclude to the WHOLE invocation rather than to the pathspecs beside it: while the two
# lists were one, the exclude cancelled the explicit web/showcase entry, and the v2.4.1
# release commit carried none of the 133 showcase files its own Step 4c had just
# rewritten. Lifted by AST and run for real - a list that stages nothing under
# web/showcase fails here, before a cut can tag it.
$path = (Resolve-Path scripts/cut-release.ps1).Path
$ast = [System.Management.Automation.Language.Parser]::ParseFile($path, [ref]$null, [ref]$null)
$fn = $ast.FindAll({
param($n)
$n -is [System.Management.Automation.Language.FunctionDefinitionAst] -and $n.Name -eq 'Get-ReleaseCommitPathspecs'
}, $true) | Select-Object -First 1
if (-not $fn) { throw 'cut-release.ps1 no longer defines Get-ReleaseCommitPathspecs' }
Invoke-Expression $fn.Extent.Text
$repoRoot = (Get-Location).Path
$list = @(Get-ReleaseCommitPathspecs $true $false)
# An exclude anywhere in this list reaches every path in the same add, whatever it was
# written to narrow, so none may stand in it at all.
$excludes = @($list | Where-Object { $_ -like '*:(exclude*' -or $_ -like ':!*' })
if ($excludes) { throw "the release staging list carries an exclude pathspec: $($excludes -join ', ')" }
if ($list -notcontains 'web/showcase') { throw 'the release staging list no longer names web/showcase' }
$showcaseFile = @(git ls-files -- web/showcase)[0]
if (-not $showcaseFile) { throw 'expected committed files under web/showcase' }
try {
Add-Content -Path $showcaseFile -Value 'touched'
$staged = @(git add --dry-run -- @($list))
if ($LASTEXITCODE -ne 0) { throw 'git add --dry-run refused the release staging list' }
if (-not ($staged -contains "add '$showcaseFile'")) {
throw "the release staging list leaves $showcaseFile out; a regenerated showcase would miss the release commit"
}
} finally {
git checkout -- $showcaseFile
}
if (git status --porcelain -- web/showcase) { throw 'the probe left web/showcase changed' }
Write-Host 'release staging: a rewritten showcase file is staged, and the list carries no exclude pathspec.'
- name: Unit-check the roadmap promotion (refusals and the real rewrite)
shell: pwsh
run: |
# The positive path, exercised against the shipped functions rather than a
# whole cut. Running the real cut for this needed a git remote the runner's
# shallow checkout could not push to, and would then have reached Step 4,
# which builds the example catalogue through the Windows-only mvnw.cmd. None
# of that is what the promotion does; all of it is what stood between CI and
# ever reaching it.
#
# Functions are lifted by AST rather than by brace-scanning, so the extraction
# cannot silently pick up half a body, and the code under test is the code
# that ships.
$path = (Resolve-Path scripts/cut-release.ps1).Path
$ast = [System.Management.Automation.Language.Parser]::ParseFile($path, [ref]$null, [ref]$null)
$want = @('Get-VersionLine', 'Get-RoadmapSectionLine', 'Get-RoadmapCurrentStableSection',
'Get-RoadmapUpcomingHeading', 'Get-RoadmapUpcomingSection',
'Test-RoadmapCurrentStable', 'Assert-RoadmapReadyForCut',
'Update-RoadmapCurrentStable')
$found = $ast.FindAll({
param($n)
$n -is [System.Management.Automation.Language.FunctionDefinitionAst] -and $want -contains $n.Name
}, $true)
$missing = $want | Where-Object { $_ -notin ($found | ForEach-Object { $_.Name }) }
if ($missing) { throw "cut-release.ps1 no longer defines: $($missing -join ', ')" }
Invoke-Expression (($found | ForEach-Object { $_.Extent.Text }) -join "`n")
# The two ambient names the roadmap functions read from the script's scope.
function Note($m) { Write-Host " $m" }
$DryRun = $false
function New-Roadmap($staged) {
# A scratch repoRoot per case, so each starts from the committed roadmap —
# with whatever it stages taken out. Every case below stages exactly what it
# is about, so a section the repository happens to carry would answer for
# them: case 1 would find the line prepared and not refuse, and case 4's
# promotion would leave that heading behind and trip its own check.
$dir = Join-Path ([IO.Path]::GetTempPath()) ([guid]::NewGuid())
New-Item -ItemType Directory -Path $dir | Out-Null
$text = [regex]::Replace((Get-Content ROADMAP.md -Raw),
'(?ms)^##\s+Upcoming\s*[—-].*?(?=^##\s|\z)', '')
if ($staged) {
$text = $text -replace "## Current stable — $([regex]::Escape($script:line))", "$staged## Current stable — $($script:line)"
}
Set-Content -Path (Join-Path $dir 'ROADMAP.md') -Value $text -NoNewline
return $dir
}
$script:line = (Select-String -Path ROADMAP.md -Pattern '##\s+Current stable\s*[—-]\s*(\d+\.\d+)').Matches[0].Groups[1].Value
$major, $minor = $script:line.Split('.')
$next = "$major.$([int]$minor + 1)"
$crossing = "$next.0"
# 1. Nothing staged — refused, naming what to prepare.
$repoRoot = New-Roadmap $null
$err = $null
try { Assert-RoadmapReadyForCut (Join-Path $repoRoot 'ROADMAP.md') $crossing } catch { $err = "$_" }
if ($err -notmatch 'Prepare the new line before cutting') { throw "unstaged: expected the preparation instructions, got: $err" }
# 2. Staged but carrying no release — refused. This is the one that used to
# slip through: the heading looked prepared, the body had nothing to promote,
# and the no-op rewrite surfaced only after every pom had moved.
$repoRoot = New-Roadmap "## Upcoming — $next`n`nProse with no bolded version at all.`n`n"
$err = $null
try { Assert-RoadmapReadyForCut (Join-Path $repoRoot 'ROADMAP.md') $crossing } catch { $err = "$_" }
if ($err -notmatch 'carries no bolded version for the cut to promote') { throw "malformed: expected the no-version refusal, got: $err" }
# 3. Staged with a version off the line being cut — refused. The wrong line is
# derived, not written down: a literal like $major.9.9 stops being wrong the
# day the project reaches 2.8, when it becomes the very line being cut and
# this case quietly starts asserting the opposite of its name.
$offLine = "$major.$([int]$minor + 2).0"
$repoRoot = New-Roadmap "## Upcoming — $next`n`n**$offLine** wrong line.`n`n"
$err = $null
try { Assert-RoadmapReadyForCut (Join-Path $repoRoot 'ROADMAP.md') $crossing } catch { $err = "$_" }
if ($err -notmatch 'is not on the .* line this cut releases') { throw "off-line: expected the line-mismatch refusal, got: $err" }
# 4. Staged correctly — accepted, and the promotion actually rewrites the file.
$repoRoot = New-Roadmap "## Upcoming — $next`n`n**$crossing** is the current release. Rehearsal prose.`n`n"
$roadmap = Join-Path $repoRoot 'ROADMAP.md'
Assert-RoadmapReadyForCut $roadmap $crossing
Update-RoadmapCurrentStable $roadmap $crossing
$after = Get-Content $roadmap -Raw
if ($after -notmatch "##\s+Current stable\s*[—-]\s*$([regex]::Escape($next))") { throw "the staged section was not promoted to 'Current stable — $next'" }
if ($after -notmatch "##\s+Previously\s*[—-]\s*$([regex]::Escape($script:line))") { throw "the $($script:line) line was not demoted to 'Previously'" }
if ($after -match '##\s+Upcoming') { throw "the staged 'Upcoming' heading survived the promotion" }
if ($after -notmatch "\*\*$([regex]::Escape($crossing))\*\*") { throw "the promoted section does not name $crossing" }
if (-not (Test-RoadmapCurrentStable $crossing)) { throw "the promoted roadmap does not satisfy the script's own post-check" }
# 5. Idempotent: re-running against the promoted file is a no-op, not a throw.
Update-RoadmapCurrentStable $roadmap $crossing
if ((Get-Content $roadmap -Raw) -ne $after) { throw "a second promotion changed the file" }
Write-Host "roadmap promotion: 3 refusals, 1 rewrite, idempotent on re-run."
- name: Unit-check the japicmp previous-release baseline move
shell: pwsh
run: |
# The japicmp previous-release pin has to move onto the version just published
# at every cut (docs/api-stability.md, Binary-compatibility enforcement), so the
# move is code that has to be exercised, not a step someone remembers. Lifted by
# AST like the checks above, so the code under test is the code that ships.
$path = (Resolve-Path scripts/cut-release.ps1).Path
$ast = [System.Management.Automation.Language.Parser]::ParseFile($path, [ref]$null, [ref]$null)
$want = @('Update-JapicmpPreviousBaseline', 'Update-JapicmpMajorBaseline')
$fn = $ast.FindAll({
param($n)
$n -is [System.Management.Automation.Language.FunctionDefinitionAst] -and $want -contains $n.Name
}, $true)
$missing = $want | Where-Object { $_ -notin ($fn | ForEach-Object { $_.Name }) }
if ($missing) { throw "cut-release.ps1 no longer defines: $($missing -join ', ')" }
Invoke-Expression (($fn | ForEach-Object { $_.Extent.Text }) -join "`n")
function Note($m) { Write-Host " $m" }
$DryRun = $false
# A function nothing calls moves no pin. Assert the post-release path still
# invokes every one of them: the failure this guards is a helper surviving a
# refactor that drops the call, which every content assertion below would still
# pass — the pin it moves simply stops moving.
$calls = $ast.FindAll({
param($n)
$n -is [System.Management.Automation.Language.CommandAst] -and
$want -contains $n.GetCommandName()
}, $true)
$called = $calls | ForEach-Object { $_.GetCommandName() } | Select-Object -Unique
$uncalled = $want | Where-Object { $_ -notin $called }
if ($uncalled) { throw "nothing in cut-release.ps1 calls: $($uncalled -join ', ')" }
function New-Pom($previous) {
$file = Join-Path ([IO.Path]::GetTempPath()) ("pom-" + [guid]::NewGuid().ToString() + ".xml")
Set-Content -Path $file -NoNewline -Value (@(
'<project>',
' <version>2.4.1-SNAPSHOT</version>',
' <properties>',
' <japicmp.baseline.floor>2.0.0</japicmp.baseline.floor>',
" <japicmp.baseline.previous>$previous</japicmp.baseline.previous>",
' <japicmp.break.binary>false</japicmp.break.binary>',
' </properties>',
'</project>'
) -join "`n")
return $file
}
$pom = New-Pom '2.3.0'
if (-not (Update-JapicmpPreviousBaseline $pom '2.4.0')) { throw 'moving the pin reported no change' }
$after = Get-Content $pom -Raw
if ($after -notmatch '<japicmp\.baseline\.previous>2\.4\.0</japicmp\.baseline\.previous>') {
throw "the previous-release pin did not move:`n$after"
}
if ($after -notmatch '<japicmp\.baseline\.floor>2\.0\.0</japicmp\.baseline\.floor>') {
throw "the major floor must not move at a cut:`n$after"
}
if ($after -notmatch '<version>2\.4\.1-SNAPSHOT</version>') { throw "the project version was rewritten:`n$after" }
if (Update-JapicmpPreviousBaseline $pom '2.4.0') { throw 'a second move reported a change' }
$bare = Join-Path ([IO.Path]::GetTempPath()) ("pom-" + [guid]::NewGuid().ToString() + ".xml")
Set-Content -Path $bare -NoNewline -Value "<project>`n <version>2.4.1-SNAPSHOT</version>`n</project>"
if (Update-JapicmpPreviousBaseline $bare '2.4.0') { throw 'a pom without the pin reported a change' }
if (Update-JapicmpPreviousBaseline (Join-Path ([IO.Path]::GetTempPath()) 'no-such-pom.xml') '2.4.0') {
throw 'a missing pom reported a change'
}
# And on the real templates pom, so the pattern is proven against the file it
# has to move rather than only against one written to fit it.
$real = Join-Path ([IO.Path]::GetTempPath()) ("templates-pom-" + [guid]::NewGuid().ToString() + ".xml")
Copy-Item templates/pom.xml $real
if (-not (Update-JapicmpPreviousBaseline $real '9.9.9')) { throw 'the real templates pom: the pin did not move' }
$expected = [regex]::Replace((Get-Content templates/pom.xml -Raw),
'<japicmp\.baseline\.previous>[^<]*</japicmp\.baseline\.previous>',
'<japicmp.baseline.previous>9.9.9</japicmp.baseline.previous>')
if ((Get-Content $real -Raw) -ne $expected) { throw 'the real templates pom: the move changed more than the pin' }
# Opening a major is the one release that moves the floor and turns the break
# switch back on; every other release leaves both alone (docs/api-stability.md,
# Opening a major). Until that release ships, the pins name the previous major.
$bootstrap = New-Pom '2.4.0'
if (-not (Update-JapicmpMajorBaseline $bootstrap '3.0.0')) { throw 'the X.0.0 release did not move the floor' }
$opened = Get-Content $bootstrap -Raw
if ($opened -notmatch '<japicmp\.baseline\.floor>3\.0\.0</japicmp\.baseline\.floor>') {
throw "the floor did not move onto the release that opened the major:`n$opened"
}
if ($opened -notmatch '<japicmp\.break\.binary>true</japicmp\.break\.binary>') {
throw "the break switch did not return to true:`n$opened"
}
if (Update-JapicmpMajorBaseline $bootstrap '3.0.0') { throw 'a second major move reported a change' }
if (Update-JapicmpMajorBaseline (New-Pom '2.4.0') '3.0.1') { throw 'a patch release must not move the floor' }
Write-Host 'japicmp baseline: previous moves, floor stays except at X.0.0, idempotent, the real pom moves, and the cut calls both.'
- name: Missing Node aborts both release paths
shell: pwsh
run: |
# The refusal itself, executed. ReleaseKnowledgeGateGuardTest reads the
# preflight as text — it holds that the function contains a throw and that it
# sits before the first mutation — but text cannot say whether the throw is
# reachable. Invert the condition to `if (Get-Command node)` and every
# structural assertion still passes while a cut with no Node sails through to
# a pushed tag. So the shipped function is lifted and run, with node reported
# missing, and the outcome is asserted rather than inferred.
$path = (Resolve-Path scripts/cut-release.ps1).Path
$ast = [System.Management.Automation.Language.Parser]::ParseFile($path, [ref]$null, [ref]$null)
$want = @('Test-KnowledgePackPresent', 'Assert-KnowledgeToolingAvailable')
$fn = $ast.FindAll({
param($n)
$n -is [System.Management.Automation.Language.FunctionDefinitionAst] -and $want -contains $n.Name
}, $true)
$absent = $want | Where-Object { $_ -notin ($fn | ForEach-Object { $_.Name }) }
if ($absent) { throw "cut-release.ps1 no longer defines: $($absent -join ', ')" }
# The tool list travels with them. The preflight checks every entry, so a copy
# written out here would keep passing while the shipped list rotted.
$assignment = $ast.FindAll({
param($n)
$n -is [System.Management.Automation.Language.AssignmentStatementAst] -and
$n.Left.Extent.Text -eq '$KnowledgeTools'
}, $true)
if ($assignment.Count -ne 1) {
throw "expected exactly one `$KnowledgeTools assignment, found $($assignment.Count)"
}
$lifted = (@($assignment[0].Extent.Text) + ($fn | ForEach-Object { $_.Extent.Text })) -join "`n"
Invoke-Expression $assignment[0].Extent.Text
# One case per scope, so the Get-Command shadow cannot leak into the next.
# Node presence is shadowed in both directions rather than read from the
# runner, so the healthy case proves as much as the refusals.
$case = {
param([string] $Lifted, [string] $Root, [bool] $NodeOnPath)
$repoRoot = $Root
function Note($message) { Write-Host " $message" }
function Get-Command {
[CmdletBinding()]
param([Parameter(Position = 0)][string] $Name)
if ($Name -eq 'node') {
if ($NodeOnPath) { return [pscustomobject]@{ Name = 'node' } }
return $null
}
Microsoft.PowerShell.Core\Get-Command $Name
}
Invoke-Expression $Lifted
try { Assert-KnowledgeToolingAvailable; return '' } catch { return $_.Exception.Message }
}
function New-Tree([string[]] $present) {
$root = Join-Path ([IO.Path]::GetTempPath()) ("knowledge-tree-" + [guid]::NewGuid().ToString())
foreach ($tool in $present) {
$file = Join-Path $root $tool
New-Item -ItemType Directory -Force -Path (Split-Path $file) | Out-Null
Set-Content -Path $file -Value '// fixture'
}
return $root
}
$repo = (Get-Location).Path
# 1. A pack it cannot regenerate stops the release.
$message = & $case $lifted $repo $false
if ($message -notmatch '^Node\.js is required') {
throw "pack present and node missing: expected the Node.js refusal, got '$message'"
}
# 2. Node alone is not enough — a missing tool is refused, and named, because
# the message is what tells the maintainer which install is short. The
# one tool Test-KnowledgePackPresent keys on cannot be the one dropped —
# without it the tree reads as pack-less and case 3 is what runs.
$sentinel = ($fn | Where-Object { $_.Name -eq 'Test-KnowledgePackPresent' }).Extent.Text
$dropped = $KnowledgeTools | Where-Object { $sentinel -notmatch [regex]::Escape($_) } | Select-Object -Last 1
if (-not $dropped) { throw 'every knowledge tool is the pack sentinel: case 2 has nothing to drop' }
$message = & $case $lifted (New-Tree ($KnowledgeTools | Where-Object { $_ -ne $dropped })) $true
if ($message -notmatch [regex]::Escape($dropped)) {
throw "a missing knowledge tool was not refused by name: '$message'"
}
# 3. A tree with no pack is valid and needs no Node at all: the 1.x line ships
# none, and a 1.9.x cut must not start demanding one.
$message = & $case $lifted (New-Tree @()) $false
if ($message -ne '') { throw "a tree with no knowledge pack was refused: '$message'" }
# 4. A healthy tree passes, so the three refusals are the function deciding
# rather than a harness that throws whatever it is handed.
$message = & $case $lifted $repo $true
if ($message -ne '') { throw "a complete pack with node present was refused: '$message'" }
Write-Host 'knowledge preflight: refuses without node, names a missing tool, exempts a pack-less tree, passes a healthy one.'
- name: A failed git mutation stops the cut
shell: pwsh
run: |
# PowerShell does not stop on a native non-zero exit. On the 2.4.0 cut a stale
# .git/index.lock failed the release add and commit, the script reported the
# commit anyway, and Step 7 tagged the commit before it. Invoke-Git is lifted
# by AST, so the code under test is the code that ships, and run against a
# throwaway repository holding that same lock.
$path = (Resolve-Path scripts/cut-release.ps1).Path
$tokens = $null; $errors = $null
$ast = [System.Management.Automation.Language.Parser]::ParseFile($path, [ref]$tokens, [ref]$errors)
$fn = $ast.FindAll({
param($n)
$n -is [System.Management.Automation.Language.FunctionDefinitionAst] -and $n.Name -eq 'Invoke-Git'
}, $true) | Select-Object -First 1
if (-not $fn) { throw 'cut-release.ps1 no longer defines Invoke-Git' }
Invoke-Expression $fn.Extent.Text
function Get-Refusal([scriptblock]$block) {
try { & $block 2>$null; return '' } catch { return $_.Exception.Message }
}
$repo = Join-Path ([IO.Path]::GetTempPath()) ('git-exit-' + [guid]::NewGuid().ToString())
New-Item -ItemType Directory -Path $repo | Out-Null
Push-Location $repo
try {
git init -q
git config user.email ci@example.invalid
git config user.name ci
Set-Content -Path 'a.txt' -Value 'a'
Set-Content -Path 'b c.txt' -Value 'b'
$lock = Join-Path $repo '.git/index.lock'
[IO.File]::WriteAllText($lock, '')
# 1. The control: a bare git call under the lock does not throw. Without
# this the checks below could be passing on a harness that throws anyway.
$bare = Get-Refusal { git add a.txt }
if ($bare -ne '') { throw "a bare git add threw on its own — the harness proves nothing: '$bare'" }
# 2. The same calls through Invoke-Git stop, and say which call failed.
foreach ($case in @(
@{ label = 'add'; block = { Invoke-Git add a.txt } },
@{ label = 'commit'; block = { Invoke-Git commit -m 'Release v9.9.9' } })) {
$message = Get-Refusal $case.block
if ($message -notmatch "^git $($case.label) .*failed \(exit \d+\)") {
throw "Invoke-Git $($case.label) under a held index.lock did not stop: '$message'"
}
}
# 3. Without the lock the helper passes its arguments through unchanged — a
# path with a space and a message with a space arrive as one argument each.
[IO.File]::Delete($lock)
$files = @('a.txt', 'b c.txt')
Invoke-Git add @files
Invoke-Git commit -q -m 'Release v9.9.9'
$tag = 'v9.9.9'
Invoke-Git tag -a $tag -m "Release $tag"
if ((git log -1 --format=%s) -ne 'Release v9.9.9') { throw 'the commit message did not arrive intact' }
if (((git show --name-only --format= HEAD) -join ',') -ne 'a.txt,b c.txt') { throw 'the staged paths did not arrive intact' }
if ((git tag -l --format='%(contents:subject)' $tag) -ne 'Release v9.9.9') { throw 'the tag message did not arrive intact' }
# 4. A tag that already exists and a push with no remote stop too.
if ((Get-Refusal { Invoke-Git tag -a $tag -m 'again' }) -notmatch '^git tag .*failed') { throw 'an existing tag did not stop the cut' }
if ((Get-Refusal { Invoke-Git push origin $tag }) -notmatch '^git push .*failed') { throw 'a failed push did not stop the cut' }
} finally {
Pop-Location
}
Write-Host 'Invoke-Git: stops on a held lock, an existing tag and a failed push; passes arguments intact.'
# The last git call above failed on purpose, and the Actions pwsh wrapper ends
# every step with `exit $LASTEXITCODE` — without this the step would pass every
# check and still report exit 128.
$global:LASTEXITCODE = 0