-
Notifications
You must be signed in to change notification settings - Fork 1
1205 lines (1090 loc) · 52.1 KB
/
Copy pathrelease.yml
File metadata and controls
1205 lines (1090 loc) · 52.1 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
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
name: Release
on:
push:
tags:
- "v*"
workflow_dispatch:
inputs:
tag_name:
description: Existing v* tag to release
required: true
type: string
allow_generated_notes:
description: Use GitHub auto-generated release notes instead of the curated CHANGELOG section
required: false
default: false
type: boolean
permissions:
contents: read
concurrency:
group: release-${{ github.event_name == 'workflow_dispatch' && inputs.tag_name || github.ref }}
cancel-in-progress: false
jobs:
preflight:
name: Validate release tag
if: github.repository == 'Widthdom/CodeIndex'
runs-on: ubuntu-24.04
timeout-minutes: 5
permissions:
contents: read
outputs:
tag: ${{ steps.release.outputs.tag }}
version: ${{ steps.release.outputs.version }}
ref: ${{ steps.release.outputs.ref }}
steps:
- name: Validate release tag
id: release
env:
EVENT_NAME: ${{ github.event_name }}
INPUT_TAG_NAME: ${{ inputs.tag_name }}
REF_NAME: ${{ github.ref_name }}
REF_TYPE: ${{ github.ref_type }}
run: |
set -euo pipefail
if [ "$EVENT_NAME" = "workflow_dispatch" ]; then
tag="$INPUT_TAG_NAME"
if [ "$REF_TYPE" != "tag" ] || [ "$REF_NAME" != "$tag" ]; then
echo "Manual releases must run this workflow from the same tag ref requested by tag_name; got ${REF_TYPE}:${REF_NAME} for ${tag}." >&2
exit 1
fi
else
if [ "$REF_TYPE" != "tag" ]; then
echo "Release workflow must run from a tag ref, got ${REF_TYPE}:${REF_NAME}." >&2
exit 1
fi
tag="$REF_NAME"
fi
if [[ ! "$tag" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]]; then
echo "Release tag must be a v-prefixed SemVer version, got: ${tag}" >&2
exit 1
fi
echo "tag=${tag}" >> "$GITHUB_OUTPUT"
echo "version=${tag#v}" >> "$GITHUB_OUTPUT"
echo "ref=refs/tags/${tag}" >> "$GITHUB_OUTPUT"
release:
if: github.repository == 'Widthdom/CodeIndex'
needs: preflight
timeout-minutes: 90
permissions:
contents: read
strategy:
matrix:
# Keep this matrix in sync with docs/platform-support.md and install.sh
# unsupported-RID guidance.
# この matrix は docs/platform-support.md と install.sh の未対応 RID
# ガイダンスと同期する。
include:
- os: ubuntu-24.04
rid: linux-x64
- os: ubuntu-24.04
rid: linux-arm64
cross_compile: true
- os: windows-2022
rid: win-x64
- os: windows-2022
rid: win-arm64
cross_compile: true
- os: macos-14
rid: osx-arm64
runs-on: ${{ matrix.os }}
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
ref: ${{ needs.preflight.outputs.ref }}
- name: Configure Windows test host
if: runner.os == 'Windows' && !matrix.cross_compile
shell: pwsh
run: ./.github/scripts/configure-windows-test-host.ps1 -Workspace "${{ github.workspace }}"
- name: Set up .NET SDKs
if: ${{ !matrix.cross_compile }}
uses: actions/setup-dotnet@9a946fdbd5fb07b82b2f5a4466058b876ab72bb2 # v5.3.0
with:
dotnet-version: |
8.0.413
9.0.301
- name: Set up cross-compile .NET SDK
if: matrix.cross_compile
uses: actions/setup-dotnet@9a946fdbd5fb07b82b2f5a4466058b876ab72bb2 # v5.3.0
with:
dotnet-version: 9.0.301
- name: Cache native NuGet packages
if: ${{ !matrix.cross_compile }}
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: |
~/.nuget/packages
~\AppData\Local\NuGet\packages
key: ${{ runner.os }}-release-nuget-${{ hashFiles('src/CodeIndex/packages.lock.json', 'tests/CodeIndex.HookIsolationFixture/packages.lock.json', 'tests/CodeIndex.Tests/packages.lock.json', 'tools/CodeIndex.Changelog/packages.lock.json', 'tools/CodeIndex.PackageNormalize/packages.lock.json', 'tools/CodeIndex.TestTelemetry/packages.lock.json') }}
- name: Cache cross-compile NuGet packages
if: matrix.cross_compile
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: |
~/.nuget/packages
~\AppData\Local\NuGet\packages
key: ${{ runner.os }}-release-cross-nuget-${{ hashFiles('src/CodeIndex/packages.lock.json') }}
# --locked-mode requires every resolved package to match the committed
# packages.lock.json so an unexpected transitive bump (including silent
# native swaps under SQLitePCLRaw.bundle_e_sqlite3) fails the release
# restore instead of slipping into published artifacts. See issue #1556.
# --locked-mode は packages.lock.json と完全一致する解決を強制するため、
# SQLitePCLRaw.bundle_e_sqlite3 の native を含む推移依存の予期せぬ
# bump が公開アーティファクトに紛れ込まず release restore で気付ける。
# 詳細は issue #1556 参照。
- name: Restore test dependencies
if: ${{ !matrix.cross_compile }}
run: dotnet restore tests/CodeIndex.Tests/CodeIndex.Tests.csproj -p:RestoreTargetFrameworks=net8.0 --locked-mode
- name: Restore publish dependencies
if: matrix.cross_compile
run: dotnet restore src/CodeIndex/CodeIndex.csproj --locked-mode
- name: Build tests
if: ${{ !matrix.cross_compile }}
run: dotnet build tests/CodeIndex.Tests/CodeIndex.Tests.csproj --configuration Release --framework net8.0 --no-restore
- name: Test net8
if: ${{ !matrix.cross_compile }}
run: dotnet test tests/CodeIndex.Tests/CodeIndex.Tests.csproj --configuration Release --framework net8.0 --no-build --no-restore --nologo
# Generate a CycloneDX SBOM once per release on the linux-x64 lane.
# SBOM content is RID-independent (it lists NuGet package dependencies
# including the bundled SQLitePCLRaw native asset), so producing it on
# every matrix entry would just upload identical bytes four times.
# Pinned to a known-stable major to avoid silent CLI surface drift from
# an upstream major release breaking the release workflow.
# SBOM はリリースごとに 1 回だけ生成すれば十分 (CycloneDX 出力は NuGet
# 依存ツリーと SQLitePCLRaw のネイティブアセットを列挙するので RID 間で
# 内容は同一)。upstream の major 変更で release workflow が黙って壊れない
# よう、安定メジャーをピン留めする。
- name: Cache CycloneDX SBOM tool (linux-x64 only)
if: matrix.rid == 'linux-x64'
id: cyclonedx-tool-cache
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: ~/.dotnet/tools
key: ${{ runner.os }}-cyclonedx-6.2.0
- name: Install CycloneDX SBOM tool (linux-x64 only)
if: matrix.rid == 'linux-x64' && steps.cyclonedx-tool-cache.outputs.cache-hit != 'true'
run: dotnet tool install --global CycloneDX --version 6.2.0
# actions/setup-dotnet does not put $HOME/.dotnet/tools on PATH, including
# after a cache hit, so publish it for every linux-x64 run.
# cache hit 後も actions/setup-dotnet は $HOME/.dotnet/tools を PATH に
# 追加しないため、linux-x64 の各 run で明示的に公開する。
- name: Add CycloneDX SBOM tool to PATH (linux-x64 only)
if: matrix.rid == 'linux-x64'
run: echo "$HOME/.dotnet/tools" >> "$GITHUB_PATH"
- name: Generate CycloneDX SBOM (linux-x64 only)
if: matrix.rid == 'linux-x64'
run: |
mkdir -p sbom
dotnet-CycloneDX src/CodeIndex/CodeIndex.csproj \
--output sbom \
--output-format Json \
--exclude-test-projects \
--filename cdidx.sbom.cdx.json
test -s sbom/cdidx.sbom.cdx.json
- name: Upload SBOM artifact (linux-x64 only)
if: matrix.rid == 'linux-x64'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: CodeIndex-sbom
retention-days: 14
path: sbom/cdidx.sbom.cdx.json
# The runtime-specific restore that publish triggers consumes the
# versions pinned by the lock file because Directory.Build.props sets
# RestorePackagesWithLockFile=true. We deliberately do not pass
# RestoreLockedMode=true here: publish for a new RID and trimming
# (Microsoft.NET.ILLink.Tasks) legitimately add lock entries that did
# not exist at the preceding locked restore (net8 test-project restore on
# native lanes, production-project restore on cross-compile lanes). The
# native restore covers the production dependency graph through the test
# project's ProjectReference. The security guarantee against silent
# SQLitePCLRaw-style transitive drift is already enforced by that
# lane-appropriate restore. See issue #1556 / DEVELOPER_GUIDE.md.
# publish が target RID 向けに走らせる runtime-specific restore は
# Directory.Build.props の RestorePackagesWithLockFile=true により
# lock ファイルで固定された版を解決対象とする。ここで
# RestoreLockedMode=true を渡さないのは意図的で、publish 時に新規 RID と
# trimming(Microsoft.NET.ILLink.Tasks)が、直前の locked restore(native lane
# では net8 test project、cross-compile lane では production project)時には
# なかった lock エントリを正当に追加するため。native restore は test project
# の ProjectReference 経由で production dependency graph も検証する。
# SQLitePCLRaw のような推移依存ドリフトに対する保証は、その lane に対応する
# restore で確保済み。
# 詳細は issue #1556 / DEVELOPER_GUIDE.md。
- name: Publish (self-contained)
run: >
dotnet publish src/CodeIndex/CodeIndex.csproj
--configuration Release
--runtime ${{ matrix.rid }}
--self-contained true
-p:PublishSingleFile=true
-p:PublishTrimmed=true
--output publish
- name: Remove debugging symbols (Linux/macOS)
if: runner.os != 'Windows'
run: find publish -name '*.pdb' -delete
- name: Remove debugging symbols (Windows)
if: runner.os == 'Windows'
shell: pwsh
run: Get-ChildItem publish -Filter *.pdb -Recurse | Remove-Item
- name: Sign Windows executable if configured
if: runner.os == 'Windows'
shell: pwsh
env:
WIN_SIGNING_CERT_BASE64: ${{ secrets.WIN_SIGNING_CERT_BASE64 }}
WIN_SIGNING_CERT_PASSWORD: ${{ secrets.WIN_SIGNING_CERT_PASSWORD }}
run: |
if ([string]::IsNullOrWhiteSpace($env:WIN_SIGNING_CERT_BASE64) -or [string]::IsNullOrWhiteSpace($env:WIN_SIGNING_CERT_PASSWORD)) {
Write-Warning "Windows Authenticode signing secrets are not configured; publishing unsigned Windows release binary."
exit 0
}
$exe = Join-Path (Resolve-Path publish) "cdidx.exe"
if (-not (Test-Path -LiteralPath $exe)) {
throw "Published Windows executable was not found: $exe"
}
$pfxPath = Join-Path $env:RUNNER_TEMP "cdidx-signing.pfx"
[IO.File]::WriteAllBytes($pfxPath, [Convert]::FromBase64String($env:WIN_SIGNING_CERT_BASE64))
$password = ConvertTo-SecureString $env:WIN_SIGNING_CERT_PASSWORD -AsPlainText -Force
$cert = Import-PfxCertificate `
-FilePath $pfxPath `
-CertStoreLocation Cert:\CurrentUser\My `
-Password $password `
-Exportable:$false
try {
if (-not $cert.Thumbprint) {
throw "Imported signing certificate did not expose a thumbprint."
}
$signtool = Get-ChildItem "${env:ProgramFiles(x86)}\Windows Kits\10\bin" -Recurse -Filter signtool.exe |
Where-Object { $_.FullName -match '\\x64\\signtool\.exe$' } |
Sort-Object FullName -Descending |
Select-Object -First 1
if (-not $signtool) {
throw "signtool.exe was not found in the Windows Kits installation."
}
& $signtool.FullName sign /fd SHA256 /td SHA256 /tr http://timestamp.digicert.com /sha1 $cert.Thumbprint $exe
if ($LASTEXITCODE -ne 0) {
throw "signtool sign failed with exit code $LASTEXITCODE."
}
& $signtool.FullName verify /pa /v $exe
if ($LASTEXITCODE -ne 0) {
throw "signtool verify failed with exit code $LASTEXITCODE."
}
} finally {
if ($cert.Thumbprint) {
Remove-Item -LiteralPath "Cert:\CurrentUser\My\$($cert.Thumbprint)" -Force -ErrorAction SilentlyContinue
}
Remove-Item -LiteralPath $pfxPath -Force -ErrorAction SilentlyContinue
}
- name: Add license and trademark notices to publish output (Linux/macOS)
if: runner.os != 'Windows'
run: cp LICENSE LICENSES/FSL-1.1-ALv2.txt LICENSES/Apache-2.0.txt COMMERCIAL_LICENSE.md INTEGRATION_POLICY.md TRADEMARKS.md publish/ && cp -R LICENSES publish/
- name: Add license and trademark notices to publish output (Windows)
if: runner.os == 'Windows'
shell: pwsh
run: |
Copy-Item LICENSE,COMMERCIAL_LICENSE.md,INTEGRATION_POLICY.md,TRADEMARKS.md publish
Copy-Item LICENSES publish -Recurse
- name: Archive release artifacts (Linux/macOS)
if: runner.os != 'Windows'
run: |
mkdir -p artifacts
cd publish
find . -exec touch -t 200001010000 {} +
if command -v sha256sum >/dev/null 2>&1; then
hash_file() { sha256sum "$1"; }
else
hash_file() { shasum -a 256 "$1"; }
fi
find . -type f ! -name MANIFEST.sha256 ! -name .MANIFEST.sha256.tmp | sed 's#^\./##' | LC_ALL=C sort | while IFS= read -r file; do
hash_file "$file"
done > .MANIFEST.sha256.tmp
mv .MANIFEST.sha256.tmp MANIFEST.sha256
touch -t 200001010000 MANIFEST.sha256
cp MANIFEST.sha256 "../artifacts/CodeIndex-${{ matrix.rid }}.MANIFEST.sha256"
find . -type f | sed 's#^\./##' | LC_ALL=C sort > "../artifacts/CodeIndex-${{ matrix.rid }}.members"
tar czf "../artifacts/CodeIndex-${{ matrix.rid }}.tar.gz" -T "../artifacts/CodeIndex-${{ matrix.rid }}.members"
tar tzf "../artifacts/CodeIndex-${{ matrix.rid }}.tar.gz" | LC_ALL=C sort > "../artifacts/CodeIndex-${{ matrix.rid }}.actual-members"
cmp "../artifacts/CodeIndex-${{ matrix.rid }}.members" "../artifacts/CodeIndex-${{ matrix.rid }}.actual-members"
rm "../artifacts/CodeIndex-${{ matrix.rid }}.members" "../artifacts/CodeIndex-${{ matrix.rid }}.actual-members"
- name: Archive release artifacts (Windows)
if: runner.os == 'Windows'
shell: pwsh
run: |
New-Item -ItemType Directory -Force -Path artifacts
$fixedTimestamp = [DateTime]'2000-01-01T00:00:00Z'
Get-ChildItem publish -Recurse | ForEach-Object { $_.LastWriteTimeUtc = $fixedTimestamp }
$files = Get-ChildItem publish -File -Recurse |
Where-Object { $_.Name -ne 'MANIFEST.sha256' -and $_.Name -ne '.MANIFEST.sha256.tmp' } |
Sort-Object FullName
$manifestLines = foreach ($file in $files) {
$relative = [System.IO.Path]::GetRelativePath((Resolve-Path publish), $file.FullName).Replace('\', '/')
$hash = (Get-FileHash -Algorithm SHA256 -LiteralPath $file.FullName).Hash.ToLowerInvariant()
"$hash $relative"
}
$manifestLines | Set-Content -NoNewline:$false -Encoding ascii publish\MANIFEST.sha256
(Get-Item publish\MANIFEST.sha256).LastWriteTimeUtc = $fixedTimestamp
Copy-Item publish\MANIFEST.sha256 "artifacts\CodeIndex-${{ matrix.rid }}.MANIFEST.sha256"
$files = Get-ChildItem publish -File -Recurse | Sort-Object FullName
$relativeFiles = $files | ForEach-Object { [System.IO.Path]::GetRelativePath((Resolve-Path publish), $_.FullName) } | Sort-Object
$zipPath = Resolve-Path artifacts
$zipPath = Join-Path $zipPath "CodeIndex-${{ matrix.rid }}.zip"
$zip = [System.IO.Compression.ZipFile]::Open($zipPath, [System.IO.Compression.ZipArchiveMode]::Create)
try {
foreach ($relativeFile in $relativeFiles) {
$sourcePath = Join-Path (Resolve-Path publish) $relativeFile
$entryName = $relativeFile.Replace('\', '/')
[System.IO.Compression.ZipFileExtensions]::CreateEntryFromFile(
$zip,
$sourcePath,
$entryName,
[System.IO.Compression.CompressionLevel]::Optimal) | Out-Null
}
} finally {
$zip.Dispose()
}
$zip = [System.IO.Compression.ZipFile]::OpenRead((Resolve-Path "artifacts\CodeIndex-${{ matrix.rid }}.zip"))
try {
$actual = $zip.Entries | Where-Object { $_.Name } | ForEach-Object { $_.FullName } | Sort-Object
$expected = $relativeFiles | ForEach-Object { $_.Replace('\', '/') } | Sort-Object
if (@($actual).Count -ne @($expected).Count) {
throw "Archive member count mismatch: expected $(@($expected).Count), got $(@($actual).Count)"
}
for ($i = 0; $i -lt @($expected).Count; $i++) {
if (@($actual)[$i] -ne @($expected)[$i]) {
throw "Archive member mismatch at index ${i}: expected '$(@($expected)[$i])', got '$(@($actual)[$i])'"
}
}
} finally {
$zip.Dispose()
}
- name: Upload release artifacts
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: CodeIndex-${{ matrix.rid }}
retention-days: 14
path: artifacts/**
prepare-release-files:
if: github.repository == 'Widthdom/CodeIndex'
runs-on: ubuntu-24.04
needs: [preflight, release]
timeout-minutes: 30
permissions:
contents: read
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ needs.preflight.outputs.ref }}
- name: Set up .NET
if: ${{ !inputs.allow_generated_notes }}
uses: actions/setup-dotnet@9a946fdbd5fb07b82b2f5a4466058b876ab72bb2 # v5.3.0
with:
dotnet-version: |
8.0.413
9.0.301
cache: true
cache-dependency-path: tools/CodeIndex.Changelog/packages.lock.json
- name: Restore changelog tool dependencies
if: ${{ !inputs.allow_generated_notes }}
run: dotnet restore tools/CodeIndex.Changelog/CodeIndex.Changelog.csproj --locked-mode
- name: Download all artifacts
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
pattern: CodeIndex-*
path: all-artifacts
- name: Collect release files
run: |
mkdir -p release-files
# Tarballs/zips are per-RID artifacts; the SBOM and installer script
# are RID-independent files. Put all of them in release-files so
# sha256sums.txt covers the bytes that supply-chain consumers and
# `cdidx upgrade` verify before use.
# tarball/zip は RID 別の成果物、SBOM と installer script は RID 非依存の
# ファイル。すべて release-files に集約することで sha256sums.txt が
# supply-chain consumer と `cdidx upgrade` が使用前に検証する bytes を
# カバーする。
find all-artifacts -type f \( -name '*.tar.gz' -o -name '*.zip' -o -name '*.cdx.json' -o -name '*.MANIFEST.sha256' \) -exec cp {} release-files/ \;
cp install.sh release-files/install.sh
cd release-files
expected_rids="linux-x64 linux-arm64 osx-arm64 win-x64 win-arm64"
for rid in $expected_rids; do
case "$rid" in
win-*) asset="CodeIndex-${rid}.zip" ;;
*) asset="CodeIndex-${rid}.tar.gz" ;;
esac
test -f "$asset" \
|| { echo "Missing release archive for ${rid}: ${asset}" >&2; ls -la >&2; exit 1; }
done
if compgen -G 'CodeIndex-osx-x64.*' >/dev/null; then
echo "Unexpected osx-x64 release archive found; update platform-support docs and installer guidance before publishing it." >&2
ls -la CodeIndex-osx-x64.* >&2
exit 1
fi
sha256sum * > sha256sums.txt
- name: Write release install notes
run: |
cat > release-install-notes.md <<'EOF'
## Install or update
Homebrew:
```bash
brew install widthdom/tap/codeindex
brew upgrade widthdom/tap/codeindex
```
NuGet:
```bash
dotnet tool install -g cdidx
dotnet tool update -g cdidx
```
EOF
- name: Write curated release notes
if: ${{ !inputs.allow_generated_notes }}
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG_NAME: ${{ needs.preflight.outputs.tag }}
run: |
set -euo pipefail
version="${TAG_NAME#v}"
previous_tag="$(
gh release list \
--exclude-drafts \
--exclude-pre-releases \
--limit 20 \
--json tagName \
--jq ".[] | select(.tagName != \"${TAG_NAME}\") | .tagName" \
| sed -n '1p'
)"
if [ -z "${previous_tag}" ]; then
echo "No previous non-draft, non-prerelease GitHub release was found before ${TAG_NAME}." >&2
exit 1
fi
if [[ ! "${previous_tag}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "Latest GitHub release tag is not a v-prefixed SemVer version: ${previous_tag}" >&2
exit 1
fi
previous_version="${previous_tag#v}"
dotnet run --project tools/CodeIndex.Changelog --no-restore -- release-notes --version "${version}" --previous-version "${previous_version}" > release-notes.md
- name: Upload prepared release payload
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: release-payload
path: |
release-files/**
release-*.md
if-no-files-found: error
retention-days: 1
create-release:
if: github.repository == 'Widthdom/CodeIndex'
runs-on: ubuntu-24.04
needs: [preflight, prepare-release-files]
timeout-minutes: 30
permissions:
contents: write
id-token: write
attestations: write
environment: release-production
steps:
- name: Download prepared release payload
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
pattern: release-payload
path: .
merge-multiple: true
- name: Import release GPG key
env:
RELEASE_GPG_PRIVATE_KEY: ${{ secrets.RELEASE_GPG_PRIVATE_KEY }}
RELEASE_GPG_PASSPHRASE: ${{ secrets.RELEASE_GPG_PASSPHRASE }}
GNUPGHOME: ${{ runner.temp }}/release-gnupg
run: |
set -euo pipefail
if [ -z "${RELEASE_GPG_PRIVATE_KEY}" ]; then
echo "RELEASE_GPG_PRIVATE_KEY secret is required to sign sha256sums.txt." >&2
exit 1
fi
mkdir -p "$GNUPGHOME"
chmod 700 "$GNUPGHOME"
printf '%s' "${RELEASE_GPG_PRIVATE_KEY}" | gpg --batch --import
if [ -n "${RELEASE_GPG_PASSPHRASE}" ]; then
printf '%s' "${RELEASE_GPG_PASSPHRASE}" > "$GNUPGHOME/release-passphrase"
chmod 600 "$GNUPGHOME/release-passphrase"
fi
- name: Sign release checksum manifest
env:
RELEASE_GPG_KEY_ID: ${{ secrets.RELEASE_GPG_KEY_ID }}
RELEASE_GPG_PASSPHRASE: ${{ secrets.RELEASE_GPG_PASSPHRASE }}
GNUPGHOME: ${{ runner.temp }}/release-gnupg
run: |
set -euo pipefail
sign_args=(--batch --yes --armor --detach-sign)
if [ -n "${RELEASE_GPG_KEY_ID}" ]; then
sign_args+=(--local-user "${RELEASE_GPG_KEY_ID}")
fi
if [ -n "${RELEASE_GPG_PASSPHRASE}" ]; then
sign_args+=(--pinentry-mode loopback --passphrase-file "$GNUPGHOME/release-passphrase")
fi
gpg "${sign_args[@]}" --output release-files/sha256sums.txt.asc release-files/sha256sums.txt
test -s release-files/sha256sums.txt.asc
- name: Remove release GPG material
if: always()
env:
GNUPGHOME: ${{ runner.temp }}/release-gnupg
run: rm -rf "$GNUPGHOME"
- name: Attest release artifacts
uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1
with:
subject-path: |
release-files/*.tar.gz
release-files/*.zip
release-files/*.cdx.json
release-files/*.MANIFEST.sha256
release-files/install.sh
release-files/sha256sums.txt
release-files/sha256sums.txt.asc
- name: Create GitHub release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_REPO: ${{ github.repository }}
TAG_NAME: ${{ needs.preflight.outputs.tag }}
USE_GENERATED_NOTES: ${{ inputs.allow_generated_notes || false }}
run: |
set -euo pipefail
# Idempotent on workflow re-runs: if a transient failure in a later
# step (e.g. CDN propagation race in the verify step) caused us to
# re-run from this point, the release with this tag already exists
# and `gh release create` would fail with
# "a release with the same tag name already exists".
# In that case, upload any missing assets with --clobber instead so
# the release stays consistent without manual intervention.
# 再実行時の冪等性: 後続ステップの一時的失敗(例: verify ステップで
# CDN 伝播競合)でこのジョブが再実行された場合、同名タグの release
# は既に存在するため `gh release create` は
# "a release with the same tag name already exists"
# で失敗する。その場合は不足アセットを --clobber 付きで upload し、
# release を手動介入なしに整合させる。
if gh release view "${TAG_NAME}" >/dev/null 2>&1; then
echo "Release ${TAG_NAME} already exists; uploading missing assets with --clobber."
gh release upload "${TAG_NAME}" release-files/* --clobber
elif [ "${USE_GENERATED_NOTES}" = "true" ]; then
gh release create "${TAG_NAME}" \
release-files/* \
--verify-tag \
--notes-file release-install-notes.md \
--generate-notes
else
gh release create "${TAG_NAME}" \
release-files/* \
--verify-tag \
--notes-file release-notes.md
fi
- name: Wait for release assets to be downloadable
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_REPO: ${{ github.repository }}
TAG_NAME: ${{ needs.preflight.outputs.tag }}
# install.sh downloads BOTH the linux-x64 tarball and sha256sums.txt,
# and the CDN can propagate each release asset independently. Polling
# only the tarball let the verify step run while sha256sums.txt was
# still 404 on the download CDN, which is exactly how the v1.22.1
# release failed ("Failed to download sha256sums.txt ... HTTP 404").
# Poll every asset the verify step fetches so a slow-propagating
# asset is waited out instead of failing the release.
# install.sh は linux-x64 tarball と sha256sums.txt の両方を
# ダウンロードし、CDN は各 release asset を独立に伝播させる。
# tarball だけをポーリングしていたため、sha256sums.txt がまだ
# download CDN 上で 404 のまま verify step が走り、v1.22.1 の
# リリースが "Failed to download sha256sums.txt ... HTTP 404" で
# 失敗した。verify step が取得する全 asset をポーリングし、伝播の
# 遅い asset があってもリリースを失敗させず待ち切る。
VERIFY_ASSET_NAMES: CodeIndex-linux-x64.tar.gz install.sh sha256sums.txt sha256sums.txt.asc
run: |
set -euo pipefail
mapfile -t expected_assets < <(cd release-files && printf '%s\n' *)
wait_seconds=600
interval_seconds=10
# `gh release create` / `gh release upload` can return while release
# assets are still being processed. First wait until every file we
# intend to publish is visible in the release API as an uploaded,
# non-empty asset; otherwise the download URL can legitimately 404
# for longer than a short CDN-only retry loop.
# `gh release create` / `gh release upload` は release asset の処理完了
# 前に戻り得る。まず公開対象の全ファイルが release API 上で uploaded
# かつ非空として見えるまで待つ。これを確認しないと、download URL が
# 短い CDN 待機時間を超えて 404 になることがある。
release_api="/repos/${GITHUB_REPOSITORY}/releases/tags/${TAG_NAME}"
deadline=$((SECONDS + wait_seconds))
while :; do
if ! available_assets="$(gh api "${release_api}" --jq '.assets[] | select(.state == "uploaded" and .size > 0) | .name' | sort)"; then
if [ "$SECONDS" -ge "$deadline" ]; then
echo "Release API did not become readable in time for ${TAG_NAME}." >&2
exit 1
fi
echo "Release API is not ready for ${TAG_NAME}; retrying in ${interval_seconds}s..."
sleep "$interval_seconds"
continue
fi
missing_assets=""
for asset in "${expected_assets[@]}"; do
if ! printf '%s\n' "$available_assets" | grep -Fx -- "$asset" >/dev/null; then
missing_assets="${missing_assets} ${asset}"
fi
done
if [ -z "$missing_assets" ]; then
echo "All release assets are uploaded and non-empty in the GitHub API."
break
fi
if [ "$SECONDS" -ge "$deadline" ]; then
echo "Release assets did not finish uploading in time:${missing_assets}" >&2
gh api "${release_api}" --jq '.assets[] | {name, state, size}'
exit 1
fi
echo "Waiting for release assets to finish uploading:${missing_assets}"
sleep "$interval_seconds"
done
# The public download URL (download/<tag>/<asset>) is served via a
# CDN after the API accepts the assets. Use a tiny ranged GET instead
# of HEAD so this check matches install.sh's download path; GitHub's
# asset CDN can disagree with HEAD while GET is the operation users
# actually need.
# public download URL は API 反映後に CDN 経由で配信される。HEAD では
# なく小さな Range GET を使い、install.sh と同じ取得経路を確認する。
# GitHub の asset CDN では HEAD と GET の見え方がずれることがあり、
# ユーザーに必要なのは GET が成功すること。
for asset in $VERIFY_ASSET_NAMES; do
url="https://github.com/${GITHUB_REPOSITORY}/releases/download/${TAG_NAME}/${asset}"
reachable=
deadline=$((SECONDS + wait_seconds))
attempt=1
while :; do
code="$(curl -fsSL --connect-timeout 10 --max-time 60 --retry 5 --retry-delay 5 --retry-all-errors --range 0-0 -o /dev/null -w '%{http_code}' "$url" || true)"
if [ "$code" = "200" ] || [ "$code" = "206" ]; then
echo "Asset ${asset} reachable after $attempt attempt(s)."
reachable=1
break
fi
if [ "$SECONDS" -ge "$deadline" ]; then
break
fi
echo "Attempt $attempt: ${asset} HTTP $code, retrying in ${interval_seconds}s..."
attempt=$((attempt + 1))
sleep "$interval_seconds"
done
if [ -z "$reachable" ]; then
echo "Asset $url did not become reachable in time." >&2
exit 1
fi
done
verify-release-install:
if: github.repository == 'Widthdom/CodeIndex'
runs-on: ubuntu-24.04
needs: [preflight, create-release]
timeout-minutes: 15
permissions:
contents: read
attestations: read
steps:
- name: Verify install.sh against the published release
env:
GH_TOKEN: ${{ github.token }}
TAG_NAME: ${{ needs.preflight.outputs.tag }}
run: |
set -euo pipefail
install_url="https://github.com/${GITHUB_REPOSITORY}/releases/download/${TAG_NAME}/install.sh"
curl -fsSL --connect-timeout 10 --max-time 60 --retry 5 --retry-delay 5 --retry-all-errors -o install.sh "$install_url"
export CDIDX_INSTALL_DIR="$(mktemp -d)/bin"
mkdir -p "$CDIDX_INSTALL_DIR"
bash install.sh "${TAG_NAME}"
# This Ubuntu smoke test exercises the linux-x64 installer path. The
# release-files collection step above verifies that every published
# RID archive exists, including macOS/Windows assets.
binary_name="cdidx"
native_asset="libe_sqlite3.so"
for asset in "$binary_name" "$native_asset" version.json LICENSE COMMERCIAL_LICENSE.md INTEGRATION_POLICY.md TRADEMARKS.md LICENSES; do
test -e "$CDIDX_INSTALL_DIR/$asset" \
|| { echo "Missing $asset in $CDIDX_INSTALL_DIR" >&2; ls -la "$CDIDX_INSTALL_DIR" >&2; exit 1; }
done
test -f "$CDIDX_INSTALL_DIR/LICENSES/FSL-1.1-ALv2.txt"
test -f "$CDIDX_INSTALL_DIR/LICENSES/Apache-2.0.txt"
# --version must reflect the tag, not the v0.0.0 fallback.
# Since #1550 `cdidx --version` is `cdidx v<ver>` optionally
# followed by a ` (commit <sha>, built <date>, <clean|dirty>)`
# build-metadata suffix, so anchor on the `cdidx <tag>` prefix
# instead of an exact match — a wrong version still fails because
# the suffix can only ever follow a space.
EXPECTED="cdidx ${TAG_NAME}"
ACTUAL="$("$CDIDX_INSTALL_DIR/$binary_name" --version)"
case "$ACTUAL" in
"$EXPECTED"|"$EXPECTED "*) ;;
*)
echo "Version mismatch: expected '$EXPECTED' (optionally followed by build metadata), got '$ACTUAL'" >&2
exit 1
;;
esac
# A command that touches SQLite must not DllNotFoundException.
"$CDIDX_INSTALL_DIR/$binary_name" . >/dev/null
"$CDIDX_INSTALL_DIR/$binary_name" status >/dev/null
# CLI --json must work on the published trimmed self-contained release.
# Every CLI JSON DTO is covered by the source-generated serializer context.
json_temp_dir="${RUNNER_TEMP:?RUNNER_TEMP must be set by GitHub Actions}"
mkdir -p "$json_temp_dir"
json_stdout="$json_temp_dir/cdidx_json_stdout.txt"
json_stderr="$json_temp_dir/cdidx_json_stderr.txt"
if ! "$CDIDX_INSTALL_DIR/$binary_name" status --json >"$json_stdout" 2>"$json_stderr"; then
echo "Expected status --json to exit 0" >&2
cat "$json_stderr" >&2
exit 1
fi
[ -s "$json_stdout" ] \
|| { echo "Expected status --json stdout to contain a JSON object" >&2; cat "$json_stderr" >&2; exit 1; }
[ ! -s "$json_stderr" ] \
|| { echo "Expected no stderr for status --json" >&2; cat "$json_stderr" >&2; exit 1; }
grep -F -- '"files":' "$json_stdout" >/dev/null \
|| { echo "status --json stdout did not include files" >&2; cat "$json_stdout" >&2; exit 1; }
grep -F -- '"version":' "$json_stdout" >/dev/null \
|| { echo "status --json stdout did not include version" >&2; cat "$json_stdout" >&2; exit 1; }
publish-nuget:
if: github.repository == 'Widthdom/CodeIndex'
runs-on: ubuntu-24.04
needs: [preflight, release]
timeout-minutes: 30
permissions:
contents: read
id-token: write
attestations: write
environment: nuget-production
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ needs.preflight.outputs.ref }}
- name: Set up .NET
uses: actions/setup-dotnet@9a946fdbd5fb07b82b2f5a4466058b876ab72bb2 # v5.3.0
with:
dotnet-version: |
8.0.413
9.0.301
cache: true
cache-dependency-path: |
src/CodeIndex/packages.lock.json
tools/CodeIndex.PackageNormalize/packages.lock.json
- name: Extract version from tag
id: version
run: |
set -euo pipefail
TAG="${{ needs.preflight.outputs.tag }}"
if [[ ! "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]]; then
echo "Release tag must be a v-prefixed SemVer version, got: ${TAG}" >&2
exit 1
fi
VERSION="${TAG#v}"
VERSION_JSON="$(jq -r '.version // empty' version.json)"
if [ "$VERSION_JSON" != "$VERSION" ]; then
echo "version.json (${VERSION_JSON}) does not match release tag ${TAG} (${VERSION})." >&2
echo "Run the release changelog preparation workflow and commit version.json before tagging." >&2
exit 1
fi
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
- name: Verify NuGet version is not already published
env:
VERSION: ${{ steps.version.outputs.version }}
run: |
set -euo pipefail
package_url="https://api.nuget.org/v3-flatcontainer/cdidx/${VERSION}/cdidx.${VERSION}.nupkg"
response_headers="$(mktemp "${RUNNER_TEMP:-/tmp}/cdidx-nuget-head.XXXXXX")"
cleanup() {
rm -f "$response_headers"
}
trap cleanup EXIT
status="$(curl -sS --connect-timeout 10 --max-time 30 --retry 3 --retry-delay 2 --retry-all-errors -o "$response_headers" -w '%{http_code}' -I "$package_url")"
if [ "$status" = "200" ]; then
echo "NuGet package cdidx ${VERSION} is already published; refusing to mask this as a duplicate." >&2
exit 1
fi
if [ "$status" != "404" ]; then
echo "Could not verify NuGet package availability for cdidx ${VERSION}; HTTP ${status}." >&2
cat "$response_headers" >&2 || true
exit 1
fi
# publish-nuget is a separate job with a fresh checkout, so the lock
# file matches what is committed. RestorePackagesWithLockFile=true
# (set in Directory.Build.props) forces pack to resolve to the locked
# versions; RestoreLockedMode is intentionally NOT enabled here to
# match the publish step's rationale (see above). The locked-mode
# restore in dotnet.yml / release.yml / codeql.yml is what catches a
# drifted lock file. See issue #1556 / DEVELOPER_GUIDE.md.
# publish-nuget は別ジョブで fresh checkout から動くため、lock ファイルは
# commit 通りの状態。Directory.Build.props の RestorePackagesWithLockFile=true
# により pack の解決は lock 済みバージョンに従う。RestoreLockedMode は
# 上の publish step と同じ理由で意図的に有効化していない。lock ファイル
# ドリフトの検出は dotnet.yml / release.yml / codeql.yml の locked-mode
# restore が担う。詳細は issue #1556 / DEVELOPER_GUIDE.md。
- name: Pack NuGet package
run: >
dotnet pack src/CodeIndex/CodeIndex.csproj
--configuration Release
-p:Version=${{ steps.version.outputs.version }}
--output nupkg
# NuGet's OPC writer gives the core-properties *.psmdcp part a random
# name on each pack run. Normalize that internal path before hashing or
# publishing so otherwise identical .nupkg/.snupkg archives compare
# byte-for-byte. See issue #2756 / DEVELOPER_GUIDE.md.
# NuGet の OPC writer は core-properties の *.psmdcp part に pack ごと
# ランダムな名前を付ける。hash / publish 前に内部 path を正規化し、
# それ以外が同一の .nupkg/.snupkg archive を byte-for-byte で比較可能にする。
# 詳細は issue #2756 / DEVELOPER_GUIDE.md。
- name: Normalize NuGet package metadata part names
run: dotnet run --project tools/CodeIndex.PackageNormalize -- nupkg/*.nupkg nupkg/*.snupkg
- name: Verify normalized NuGet package metadata part names
run: |
set -euo pipefail
for package in nupkg/*.nupkg nupkg/*.snupkg; do
unzip -Z1 "$package" | grep -qx 'package/services/metadata/core-properties/core-properties.psmdcp'
if unzip -Z1 "$package" \
| grep '^package/services/metadata/core-properties/' \
| grep -vx 'package/services/metadata/core-properties/core-properties.psmdcp'; then
echo "Unexpected non-canonical NuGet core-properties part in ${package}." >&2
unzip -Z1 "$package" >&2
exit 1
fi
done
- name: Verify packed NuGet package version
env:
VERSION: ${{ steps.version.outputs.version }}
run: |
set -euo pipefail
expected_package="nupkg/cdidx.${VERSION}.nupkg"
expected_symbols="nupkg/cdidx.${VERSION}.snupkg"
if [ ! -f "$expected_package" ]; then
echo "Expected packed package ${expected_package} was not produced." >&2
printf 'Produced packages:\n' >&2
ls -la nupkg >&2
exit 1
fi
if [ ! -f "$expected_symbols" ]; then
echo "Expected packed symbol package ${expected_symbols} was not produced." >&2
printf 'Produced packages:\n' >&2
ls -la nupkg >&2
exit 1
fi
- name: Attest NuGet package artifacts
uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1
with:
subject-path: |
nupkg/*.nupkg
nupkg/*.snupkg
- name: Resolve NuGet trusted publishing user
id: nuget-user
env:
NUGET_TRUSTED_PUBLISHING_USER: ${{ vars.NUGET_TRUSTED_PUBLISHING_USER }}
run: |
set -euo pipefail
user="${NUGET_TRUSTED_PUBLISHING_USER:-}"
if [ -z "$user" ]; then
echo "GitHub Actions variable NUGET_TRUSTED_PUBLISHING_USER must be set to the NuGet.org username that created the trusted publishing policy." >&2
echo "NuGet trusted publishing matches the policy creator, not the package owner." >&2
exit 1
fi
if [[ "$user" =~ [[:space:]] ]]; then
echo "NUGET_TRUSTED_PUBLISHING_USER must be a single NuGet.org username without whitespace." >&2
exit 1
fi
echo "user=${user}" >> "$GITHUB_OUTPUT"
- name: NuGet trusted publishing login
id: nuget-login
uses: NuGet/login@ebc737b6fc418a6ca0073cf116ec8dc156d8b81e # v1
with:
user: ${{ steps.nuget-user.outputs.user }}
- name: Publish to NuGet
run: |
set -euo pipefail
dotnet nuget push nupkg/*.nupkg \
--api-key "${{ steps.nuget-login.outputs.NUGET_API_KEY }}" \
--source https://www.nuget.org/api/v2/package \
--no-symbols
dotnet nuget push nupkg/*.snupkg \
--api-key "${{ steps.nuget-login.outputs.NUGET_API_KEY }}" \
--source https://www.nuget.org/api/v2/package
publish-container:
if: github.repository == 'Widthdom/CodeIndex'
runs-on: ubuntu-24.04
needs: [preflight, verify-release-install]