-
Notifications
You must be signed in to change notification settings - Fork 0
773 lines (733 loc) · 34.2 KB
/
Copy pathrelease.yml
File metadata and controls
773 lines (733 loc) · 34.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
# Implements [CLI-INSTALL-HOMEBREW], [CLI-INSTALL-SCOOP], [CLI-INSTALL-DOTNET-TOOL]
name: Release
# Tag-triggered Shipwright release. Implements [SWR-REL-WORKFLOW], [SWR-REL-GITHUB].
#
# Pipeline: validate tag -> CI gate (on 0.0.0-dev source) -> per-platform NativeAOT
# build + per-platform VSIX -> package archives -> GitHub Release + Marketplace +
# Homebrew + Scoop -> website.
#
# DEPLOYMENT CONTRACT: the PRIMARY artifact is a self-contained NativeAOT native binary
# (zero .NET runtime on the user's machine) bundled inside the per-platform VSIX and
# shipped via GitHub Releases / Homebrew / Scoop. A `dotnet tool` NuGet package is a
# SECONDARY, best-effort channel (publish-nuget): it is non-blocking and is NOT a
# dependency of any release/marketplace/brew/scoop job, so it can never stop a release.
# .NET is a BUILD-time dependency only.
on:
push:
tags:
- "v[0-9]+.[0-9]+.[0-9]+"
- "v[0-9]+.[0-9]+.[0-9]+-*"
permissions:
contents: write
jobs:
# ── Validate the tag and derive the version ──────────────── [SWR-REL-VERSION]
validate-tag:
name: Validate tag
runs-on: ubuntu-latest
timeout-minutes: 5
outputs:
version: ${{ steps.parse.outputs.version }}
tag: ${{ steps.parse.outputs.tag }}
steps:
- name: Parse and validate tag
id: parse
shell: bash
run: |
set -euo pipefail
TAG="${GITHUB_REF_NAME}"
if [[ ! "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]]; then
echo "::error::Tag '$TAG' must match vMAJOR.MINOR.PATCH[-prerelease] (e.g. v0.11.0)"
exit 1
fi
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
echo "version=${TAG#v}" >> "$GITHUB_OUTPUT"
echo "Releasing $TAG (version ${TAG#v})"
# ── CI gate: lint + test + build + manifest validation on SOURCE (0.0.0-dev) ──
# No publish step runs until this passes. Implements [SWR-REL-WORKFLOW] gate,
# [SWR-GATE-CI].
gate:
name: CI gate
needs: validate-tag
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v6
- uses: actions/setup-dotnet@v5
with:
dotnet-version: "10.0.x"
- uses: actions/setup-node@v6
with:
node-version: 22
# Types.Generated.fs is gitignored and rebuilt from Types.td (typeDiagram DSL).
# A fresh checkout has none, so the F# build fails (FS0225) without this.
- name: Generate F# types (typeDiagram)
run: |
npm install -g typediagram@0.9.0
bash scripts/generate-types.sh
- name: Restore
run: dotnet restore
- name: Lint + build (warnings as errors)
run: dotnet build --no-restore --nologo -warnaserror
- name: Validate deployment manifest
run: npx --yes @nimblesite/shipwright-validate-manifest --schema schemas/shipwright.schema.json src/Napper.VsCode/shipwright.json
- name: Shipwright version-contract + stamper tests
# Deterministic, network-free gate: proves the --version contract and the
# release stamper. The full functional/e2e suite (which hits external
# services) runs on every PR to main per [SWR-REL-PRERELEASE-CI]; a transient
# third-party outage must never block a tagged release.
run: dotnet test src/Napper.Core.Tests --no-build --nologo --filter "FullyQualifiedName~VersionContract"
# ── Per-platform NativeAOT binary + per-platform VSIX ────── [SWR-VSIX-CI-MATRIX]
# NativeAOT cannot cross-compile across OS/arch, so each leg builds on a runner
# whose OS+arch matches the target. The binary it produces needs no .NET runtime.
build:
name: Build ${{ matrix.platform }}
needs: [validate-tag, gate]
runs-on: ${{ matrix.os }}
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
include:
- { platform: darwin-arm64, rid: osx-arm64, os: macos-15, archive: tar.gz, npm_config_arch: arm64 }
- { platform: darwin-x64, rid: osx-x64, os: macos-15-intel, archive: tar.gz, npm_config_arch: x64 }
- { platform: linux-x64, rid: linux-x64, os: ubuntu-latest, archive: tar.gz, npm_config_arch: x64 }
- { platform: linux-arm64, rid: linux-arm64, os: ubuntu-24.04-arm, archive: tar.gz, npm_config_arch: arm64 }
- { platform: win32-x64, rid: win-x64, os: windows-latest, archive: zip, npm_config_arch: x64 }
- { platform: win32-arm64, rid: win-arm64, os: windows-11-arm, archive: zip, npm_config_arch: arm }
env:
VERSION: ${{ needs.validate-tag.outputs.version }}
TAG: ${{ needs.validate-tag.outputs.tag }}
steps:
- uses: actions/checkout@v6
- uses: actions/setup-dotnet@v5
with:
dotnet-version: "10.0.x"
- uses: actions/setup-node@v6
with:
node-version: 22
cache: npm
cache-dependency-path: src/Napper.VsCode/package-lock.json
- name: Install Linux NativeAOT prerequisites
if: runner.os == 'Linux'
run: sudo apt-get update && sudo apt-get install -y clang zlib1g-dev
# Regenerate the gitignored Types.Generated.fs before any F# compile. Uses bash
# (Linux/macOS/Windows-git-bash) so it works on every native runner.
- name: Generate F# types (typeDiagram)
shell: bash
run: |
npm install -g typediagram@0.9.0
bash scripts/generate-types.sh
- name: Stamp version from tag
shell: bash
run: dotnet fsi scripts/stamp-version.fsx --tag "$TAG"
- name: Publish NativeAOT binary (${{ matrix.rid }})
shell: bash
run: |
dotnet publish src/Napper.Cli/Napper.Cli.fsproj \
-r ${{ matrix.rid }} \
-p:PublishAot=true \
-o out/${{ matrix.rid }} \
--nologo
- name: Verify binary version contract
shell: bash
run: |
set -euo pipefail
exe=""; [ "${{ runner.os }}" = "Windows" ] && exe=".exe"
BIN="out/${{ matrix.rid }}/napper$exe"
ACTUAL="$("$BIN" --version | head -1 | tr -d '\r')"
echo "napper --version -> $ACTUAL"
if [ "$ACTUAL" != "napper ${VERSION}" ]; then
echo "::error::binary version '$ACTUAL' != 'napper ${VERSION}'"
exit 1
fi
"$BIN" --version --json | node -e '
const d = JSON.parse(require("fs").readFileSync(0, "utf8"));
const v = process.env.VERSION;
const want = { manifestVersion: 1, name: "napper", version: v, kind: "cli", language: "dotnet" };
for (const [k, val] of Object.entries(want)) {
if (d[k] !== val) { console.error(`--version --json ${k}=${d[k]} expected ${val}`); process.exit(1); }
}
console.log("--version --json: OK");
'
- name: Prove binary needs no .NET runtime (clean room)
shell: bash
run: |
set -euo pipefail
exe=""; [ "${{ runner.os }}" = "Windows" ] && exe=".exe"
BIN="$PWD/out/${{ matrix.rid }}/napper$exe"
if [ "${{ runner.os }}" = "Linux" ]; then
# Pristine Ubuntu with ZERO .NET installed — the real end-user gate.
# If the AOT binary secretly needed a runtime, this is where it epic-fails.
docker run --rm -v "$PWD/out/${{ matrix.rid }}:/b" ubuntu:24.04 /b/napper --version
elif [ "${{ runner.os }}" = "macOS" ]; then
# Empty environment: no PATH, no DOTNET_ROOT — must still run standalone.
env -i "$BIN" --version
else
echo "clean-room run skipped on Windows (system DLL resolution is PATH-independent)"
fi
echo "clean-room: binary ran with no .NET runtime present"
- name: Stage raw binary for archiving
shell: bash
run: |
set -euo pipefail
exe=""; [ "${{ runner.os }}" = "Windows" ] && exe=".exe"
mkdir -p rawbin
cp "out/${{ matrix.rid }}/napper$exe" "rawbin/napper$exe"
- name: Upload raw binary
uses: actions/upload-artifact@v7
with:
name: rawbin-${{ matrix.rid }}
path: rawbin/*
if-no-files-found: error
# ── Per-platform VSIX packaging (decoupled from the native build) ─── [SWR-VSIX-PACKAGE]
# vsce packaging only ZIPS the staged native binary + manifest; it never executes the
# target binary, so it is fully cross-platform and runs entirely on Linux. Packaging
# here (instead of on each native runner) sidesteps the win32-arm npm toolchain gap —
# @vscode/vsce-sign ships no win32-arm build, so `npm ci` fails outright on a Windows
# ARM runner — and the Windows file-lock (EPERM) flakiness, while still producing one
# correctly-targeted VSIX per platform. Implements [SWR-VSIX-CI-MATRIX], [SWR-VSIX-VERIFY].
package-vsix:
name: Package VSIX ${{ matrix.platform }}
needs: [validate-tag, build]
# Ship whatever platforms built: one flaky native leg drops only its own VSIX.
if: ${{ !cancelled() && needs.build.result != 'skipped' }}
runs-on: ubuntu-latest
timeout-minutes: 15
strategy:
fail-fast: false
matrix:
include:
- { platform: darwin-arm64, rid: osx-arm64 }
- { platform: darwin-x64, rid: osx-x64 }
- { platform: linux-x64, rid: linux-x64 }
- { platform: linux-arm64, rid: linux-arm64 }
- { platform: win32-x64, rid: win-x64 }
- { platform: win32-arm64, rid: win-arm64 }
env:
VERSION: ${{ needs.validate-tag.outputs.version }}
TAG: ${{ needs.validate-tag.outputs.tag }}
steps:
- uses: actions/checkout@v6
- uses: actions/setup-dotnet@v5
with:
dotnet-version: "10.0.x"
- uses: actions/setup-node@v6
with:
node-version: 22
cache: npm
cache-dependency-path: src/Napper.VsCode/package-lock.json
# The VSIX manifest version comes from package.json (and the bundled
# shipwright.json expectedVersion), so the source carriers MUST be stamped from
# the tag before packaging — otherwise the Marketplace VSIX would ship 0.0.0-dev.
# Same first-class stamper used by the native legs ([SWR-VERSION-BUILD-STAMPING]).
- name: Stamp version from tag
run: dotnet fsi scripts/stamp-version.fsx --tag "$TAG"
# The native binary was built on its own OS/arch runner; pull just that one.
# A missing leg (e.g. an ARM-runner outage) drops only its VSIX, never the others.
- name: Download native binary for ${{ matrix.platform }}
uses: actions/download-artifact@v8
with:
name: rawbin-${{ matrix.rid }}
path: rawbin
- name: Stage binary into the extension
shell: bash
run: |
set -euo pipefail
exe=""; case "${{ matrix.platform }}" in win32-*) exe=".exe";; esac
mkdir -p "src/Napper.VsCode/bin/${{ matrix.platform }}"
cp "rawbin/napper$exe" "src/Napper.VsCode/bin/${{ matrix.platform }}/napper$exe"
chmod +x "src/Napper.VsCode/bin/${{ matrix.platform }}/napper$exe" || true
- name: Install extension dependencies
working-directory: src/Napper.VsCode
run: npm ci
- name: Compile extension
working-directory: src/Napper.VsCode
run: npx webpack --mode production
- name: Package per-platform VSIX
working-directory: src/Napper.VsCode
run: npx @vscode/vsce package --no-dependencies --skip-license --target ${{ matrix.platform }}
- name: Verify VSIX contents
shell: bash
run: |
set -euo pipefail
exe=""; case "${{ matrix.platform }}" in win32-*) exe=".exe";; esac
VSIX="$(ls src/Napper.VsCode/*.vsix | head -1)"
echo "VSIX: $VSIX"
# The packaged file name carries the stamped version: napper-<ver>.vsix.
case "$VSIX" in *"$VERSION"*) : ;; *) echo "::error::VSIX '$VSIX' does not carry version $VERSION"; exit 1;; esac
unzip -l "$VSIX" > vsix-contents.txt
cat vsix-contents.txt
grep -q "shipwright.json" vsix-contents.txt \
|| { echo "::error::shipwright.json missing from VSIX"; exit 1; }
grep -Fq "bin/${{ matrix.platform }}/napper$exe" vsix-contents.txt \
|| { echo "::error::bin/${{ matrix.platform }}/napper$exe missing from VSIX"; exit 1; }
# No foreign-platform binaries may ship in a per-platform VSIX.
if grep -E "bin/(darwin|linux|win32)-[a-z0-9]+/" vsix-contents.txt \
| grep -vq "bin/${{ matrix.platform }}/"; then
echo "::error::VSIX contains a foreign-platform binary directory"; exit 1
fi
echo "VSIX content verification: OK"
- name: Upload VSIX
uses: actions/upload-artifact@v7
with:
name: vsix-${{ matrix.platform }}
path: src/Napper.VsCode/*.vsix
if-no-files-found: error
# ── Package CLI assets uniformly on Linux ───────────────── [SWR-REL-GITHUB]
# Produces, per platform: the raw binary, an archive (.tar.gz / .zip), a per-archive
# .sha256 sidecar, and a combined checksums-sha256.txt.
package-cli:
name: Package CLI assets
needs: [validate-tag, build]
# Ship whatever platforms built: a single flaky leg (e.g. an ARM runner outage)
# must not block the release. Runs unless the gate failed (build skipped).
if: ${{ !cancelled() && needs.build.result != 'skipped' }}
runs-on: ubuntu-latest
timeout-minutes: 10
env:
TAG: ${{ needs.validate-tag.outputs.tag }}
steps:
- uses: actions/download-artifact@v8
with:
path: rawbins
pattern: rawbin-*
- name: Build archives, raw assets, and checksums
shell: bash
run: |
set -euo pipefail
mkdir -p assets
for dir in rawbins/rawbin-*; do
rid="${dir#rawbins/rawbin-}"
if [[ "$rid" == win-* ]]; then
cp "$dir/napper.exe" "assets/napper-$rid.exe"
stage="$(mktemp -d)"; cp "$dir/napper.exe" "$stage/napper.exe"
(cd "$stage" && zip -q -9 "$GITHUB_WORKSPACE/assets/napper-$TAG-$rid.zip" napper.exe)
else
cp "$dir/napper" "assets/napper-$rid"; chmod +x "assets/napper-$rid"
stage="$(mktemp -d)"; cp "$dir/napper" "$stage/napper"; chmod +x "$stage/napper"
tar -C "$stage" -czf "assets/napper-$TAG-$rid.tar.gz" napper
fi
done
# Per-archive .sha256 sidecars + a combined manifest.
cd assets
for f in napper-"$TAG"-*.tar.gz napper-"$TAG"-*.zip; do
[ -e "$f" ] || continue
sha256sum "$f" > "$f.sha256"
done
sha256sum napper-* > checksums-sha256.txt
ls -la
echo "── checksums-sha256.txt ──"; cat checksums-sha256.txt
- uses: actions/upload-artifact@v7
with:
name: cli-assets
path: assets/*
if-no-files-found: error
# ── GitHub Release with all CLI assets + per-platform VSIXs ──── [SWR-REL-GITHUB]
release:
name: Create GitHub Release
needs: [validate-tag, package-cli, package-vsix]
if: ${{ !cancelled() && needs.package-vsix.result != 'skipped' }}
runs-on: ubuntu-latest
timeout-minutes: 10
env:
TAG: ${{ needs.validate-tag.outputs.tag }}
steps:
- name: Download CLI assets
uses: actions/download-artifact@v8
with:
path: assets
name: cli-assets
- name: Download per-platform VSIXs
uses: actions/download-artifact@v8
with:
path: assets
pattern: vsix-*
merge-multiple: true
- name: List release assets
run: ls -la assets
- name: Create GitHub Release
uses: softprops/action-gh-release@v3
with:
tag_name: ${{ env.TAG }}
files: assets/*
generate_release_notes: true
draft: false
prerelease: ${{ contains(env.TAG, '-') }}
# ── Publish per-platform VSIXs to the VS Code Marketplace ─────── [SWR-VSIX-PUBLISH]
publish-marketplace:
name: Publish to VS Code Marketplace
needs: [validate-tag, package-vsix]
if: ${{ !cancelled() && needs.package-vsix.result != 'skipped' }}
runs-on: ubuntu-latest
timeout-minutes: 10
# Per-platform Marketplace publish via the Nimblesite ORG-level VSCODE_MARKETPLACE_PAT
# secret — the SAME pattern Basilisk and too-many-cooks use. napper must be on that org
# secret's repository allowlist. (NuGet/npm use OIDC; the VS Code Marketplace does not —
# the org standard is a scoped Marketplace PAT.) Implements [SWR-VSIX-PUBLISH].
permissions:
contents: read # least privilege: drop the inherited contents: write
steps:
# Turn a missing/forbidden org secret into an actionable operator error. The GitHub
# Release + Open VSX + Homebrew + Scoop do NOT depend on this job, so a missing token
# never blocks the native-binary release — only the Marketplace publish waits.
- name: Require Marketplace credential
env:
VSCE_PAT: ${{ secrets.VSCODE_MARKETPLACE_PAT }}
run: |
set -euo pipefail
if [ -z "${VSCE_PAT:-}" ]; then
echo "::error title=VSCODE_MARKETPLACE_PAT not accessible::Add a VS Code Marketplace PAT (Marketplace → Manage scope) as the Nimblesite ORG secret VSCODE_MARKETPLACE_PAT and add this repository to its allowed list (Org Settings → Secrets and variables → Actions → VSCODE_MARKETPLACE_PAT → Repository access). The GitHub Release (with all VSIX + CLI assets), Open VSX, Homebrew, and Scoop ship independently. Token guide: https://code.visualstudio.com/api/working-with-extensions/publishing-extension#get-a-personal-access-token"
exit 1
fi
echo "VSCODE_MARKETPLACE_PAT present — proceeding with Marketplace publish."
- uses: actions/setup-node@v6
with:
node-version: 22
# Install vsce ONCE at a pinned, known-replicated version. `npx @vscode/vsce`
# re-resolves the package on every call, so a transient npm `latest`-tag replication
# race (ETARGET) on a single iteration can abort the whole publish mid-loop and
# strand some platforms. One install, one binary, reused for all targets.
- name: Install vsce (pinned)
run: npm install -g @vscode/vsce@3.9.1
- name: Download all per-platform VSIXs
uses: actions/download-artifact@v8
with:
path: vsix-artifacts
pattern: vsix-*
merge-multiple: true
# One `vsce publish` per platform VSIX: vsce silently uses only the FIRST when several
# are passed in a single call (the previous single-call step published only one
# platform). Each --target VSIX MUST be published on its own. The publish is
# IDEMPOTENT — a target whose (version, platform) is already on the Marketplace
# ("already exists") counts as success, so a re-run after a partial publish completes
# the remaining platforms instead of aborting on the first duplicate. Transient errors
# retry up to 3x; one failed platform never blocks the others.
# Salvaged from repo-standardization@319159f. Implements [SWR-VSIX-PUBLISH].
- name: Publish each platform VSIX
env:
VSCE_PAT: ${{ secrets.VSCODE_MARKETPLACE_PAT }}
run: |
set -uo pipefail
shopt -s globstar nullglob
flag=""
if [[ "${GITHUB_REF_NAME}" == *-* ]]; then
flag="--pre-release"
echo "Prerelease tag ${GITHUB_REF_NAME}; publishing with --pre-release"
fi
publish_one() {
local vsix="$1" attempt out rc
for attempt in 1 2 3; do
out="$(vsce publish ${flag} --packagePath "${vsix}" 2>&1)"; rc=$?
echo "${out}"
if [ "${rc}" -eq 0 ]; then return 0; fi
if echo "${out}" | grep -qiE "already exists"; then
echo "→ ${vsix} already on Marketplace; treating as published."
return 0
fi
echo "→ attempt ${attempt} failed (rc=${rc}); retrying in $((attempt*10))s..."
sleep $((attempt*10))
done
return 1
}
published=0; failed=0
for vsix in vsix-artifacts/**/*.vsix; do
echo "Publishing ${vsix}"
if publish_one "${vsix}"; then
published=$((published + 1))
else
echo "::error::Failed to publish ${vsix} after retries"
failed=$((failed + 1))
fi
done
if [ "${published}" -eq 0 ]; then
echo "::error::No VSIX artifacts found to publish"
exit 1
fi
echo "Published/confirmed ${published} VSIX(es); ${failed} failed."
[ "${failed}" -eq 0 ]
# ── Publish per-platform VSIXs to the Open VSX Registry ──────── [SWR-VSIX-PUBLISH]
# Open VSX serves the VS Code FORKS — Cursor, Windsurf, VSCodium, Gitpod, Eclipse
# Theia, Antigravity — none of which can reach the Microsoft Marketplace. Fully
# independent of publish-marketplace: the Open VSX push must not be gated on the MS
# Marketplace publish, and vice versa. The GitHub Release + Homebrew + Scoop ship
# regardless, so a missing OVSX token can NEVER block the native-binary release —
# only the Open VSX publish waits. Implements [SWR-SEC-OIDC-PUBLISH] (per-channel).
publish-openvsx:
name: Publish to Open VSX Registry
needs: [validate-tag, package-vsix]
if: ${{ !cancelled() && needs.package-vsix.result != 'skipped' }}
runs-on: ubuntu-latest
timeout-minutes: 10
# Least privilege: this job only downloads same-run artifacts and pushes to an
# external registry, so it drops the inherited top-level `contents: write` to
# read-only. Open VSX has no OIDC/trusted-publishing path, so no `id-token` is
# granted. Implements [SWR-SEC-TOKEN-PRIVILEGE].
permissions:
contents: read
actions: read
steps:
# Turn Open VSX's opaque auth failure (what you get from an empty/blank PAT)
# into an actionable, operator-facing error before anything else runs. The
# GitHub Release, Marketplace, Homebrew, and Scoop do NOT depend on this job,
# so a missing token only stalls the Open VSX publish.
- name: Require Open VSX credential
env:
OVSX_PAT: ${{ secrets.OPEN_VSX_PAT }}
run: |
set -euo pipefail
if [ -z "${OVSX_PAT:-}" ]; then
echo "::error title=OPEN_VSX_PAT secret is not set::Add an Open VSX access token as the repo (or Nimblesite org) secret OPEN_VSX_PAT, then re-run, to publish the per-platform VSIXs to Open VSX. The GitHub Release (with all VSIX + CLI assets), VS Code Marketplace, Homebrew, and Scoop already shipped independently. Create a token at https://open-vsx.org/user-settings/tokens and create the publisher namespace once with: npx ovsx create-namespace nimblesite -p \$OVSX_PAT"
exit 1
fi
echo "OPEN_VSX_PAT present — proceeding with Open VSX publish."
- uses: actions/setup-node@v6
with:
node-version: 22
- name: Download all per-platform VSIXs
uses: actions/download-artifact@v8
with:
path: vsix-artifacts
pattern: vsix-*
merge-multiple: true
- name: Publish every per-platform VSIX to Open VSX
# The token is exposed ONLY as an env var, never on the command line: ovsx
# reads OVSX_PAT automatically when -p is omitted, keeping the secret out of
# the process argv (where a `ps`/dump could read it). ovsx is version-pinned —
# a floating `npx ovsx` would fetch and run the latest release at publish time,
# inside the very job that holds the token (a supply-chain risk). Bump it
# deliberately. Implements [SWR-SEC-FROZEN-INSTALL].
env:
OVSX_PAT: ${{ secrets.OPEN_VSX_PAT }}
run: |
set -euo pipefail
shopt -s nullglob
# One publish per platform-specific VSIX: the target is baked into each
# VSIX, so no --target flag is needed, but each must be pushed separately —
# a single glob into one call would publish only the first.
published=0
for vsix in vsix-artifacts/*.vsix; do
echo "Publishing $vsix to Open VSX"
npx --yes ovsx@1.0.0 publish --packagePath "$vsix"
published=$((published + 1))
done
if [ "$published" -eq 0 ]; then
echo "::error::No VSIX artifacts found to publish to Open VSX"
exit 1
fi
echo "Published $published VSIX(es) to Open VSX"
# ── SECONDARY, best-effort dotnet-tool NuGet package ──────────────────────────
# continue-on-error + NOT a dependency of release / marketplace / brew / scoop, so a
# NuGet failure (key, outage, pack issue) can NEVER block the release. The NativeAOT
# native binary + VSIX remain the primary, .NET-free deployment.
publish-nuget:
name: Publish dotnet tool to NuGet (best-effort)
needs: [validate-tag, gate]
runs-on: ubuntu-latest
timeout-minutes: 10
continue-on-error: true
# Protected environment so GitHub injects the `environment` claim into the OIDC token.
# The nuget.org trusted-publishing policy is scoped to Environment=release, and the
# token only carries that claim when the job is bound to the environment. MUST match.
environment: release
# KEYLESS publish — OIDC Trusted Publishing, NO long-lived NuGet API key.
# The job exchanges a short-lived GitHub OIDC token for a ~1h nuget.org API key
# via a trusted-publishing policy (owner + repository + this workflow file). This
# is MANDATORY: a static API-key secret for a package registry is forbidden.
# Implements [SWR-SEC-OIDC-PUBLISH], [SWR-SEC-NO-PAT].
permissions:
id-token: write # mint the GitHub OIDC token for the nuget.org exchange
contents: read # least privilege: drop the inherited contents: write
env:
VERSION: ${{ needs.validate-tag.outputs.version }}
# nuget.org account that OWNS the trusted-publishing policy. NuGet/login@v1 queries
# policies owned by this account; a wrong value 401s with "No matching trust policy
# owned by user '<x>'". The policy's owner is the Nimblesite ORGANIZATION account
# (nuget.org/organization/Nimblesite) — NOT an individual (ChristianFindlay/
# MelbourneDeveloper both failed; the latter isn't even a nuget.org account).
# Required input (action.yml: required: true); hardcoded here in one place.
NUGET_USER: Nimblesite
steps:
- uses: actions/checkout@v6
- uses: actions/setup-dotnet@v5
with:
dotnet-version: "10.0.x"
- uses: actions/setup-node@v6
with:
node-version: 22
- name: Generate F# types (typeDiagram)
run: |
npm install -g typediagram@0.9.0
bash scripts/generate-types.sh
- name: Pack dotnet tool (version from tag)
run: dotnet pack src/Napper.Cli/Napper.Cli.fsproj -c Release -p:Version="$VERSION" --nologo
# Exchange the GitHub OIDC token for a short-lived (~1h) nuget.org API key.
# Minted immediately before the push so it cannot expire in transit; the key is
# single-use and never stored. Implements [SWR-SEC-OIDC-PUBLISH].
- name: NuGet login (OIDC → short-lived API key)
uses: NuGet/login@v1
id: nuget_login
with:
user: ${{ env.NUGET_USER }}
- name: Push to NuGet (skip if already published)
# Push ONLY this version's package. A bare *.nupkg glob once also matched a
# stale committed napper.1.0.0.nupkg and 403'd on the foreign-owned `napper` id;
# the artifact is now gitignored, and this explicit path is belt-and-suspenders.
run: |
dotnet nuget push "src/Napper.Cli/nupkg/Nimblesite.Napper.${VERSION}.nupkg" \
--api-key "${{ steps.nuget_login.outputs.NUGET_API_KEY }}" \
--source https://api.nuget.org/v3/index.json \
--skip-duplicate
# ── Homebrew tap: hashes come from the build's .sha256 sidecars ────────────────
update-homebrew:
name: Update Homebrew Formula
needs: [validate-tag, release]
runs-on: ubuntu-latest
timeout-minutes: 10
env:
TAG: ${{ needs.validate-tag.outputs.tag }}
VERSION: ${{ needs.validate-tag.outputs.version }}
steps:
- name: Checkout homebrew-tap
uses: actions/checkout@v6
with:
repository: Nimblesite/homebrew-tap
token: ${{ secrets.BREW_SCOOP_PAT }}
- name: Read SHA256s from release sidecars
shell: bash
run: |
set -euo pipefail
BASE="https://github.com/Nimblesite/napper/releases/download/${TAG}"
sidecar() { curl -fsSL "$BASE/napper-${TAG}-$1.tar.gz.sha256" | cut -d ' ' -f 1; }
{
echo "SHA256_MACOS_ARM64=$(sidecar osx-arm64)"
echo "SHA256_MACOS_X64=$(sidecar osx-x64)"
echo "SHA256_LINUX_X64=$(sidecar linux-x64)"
echo "SHA256_LINUX_ARM64=$(sidecar linux-arm64)"
} >> "$GITHUB_ENV"
- name: Write formula
shell: bash
run: |
set -euo pipefail
mkdir -p Formula
cat > Formula/napper.rb <<FORMULA
# typed: false
# frozen_string_literal: true
class Napper < Formula
desc "CLI-first, test-oriented HTTP API testing tool. Send requests, run assertions, manage environments."
homepage "https://napperapi.dev"
license "MIT"
version "${VERSION}"
on_macos do
on_arm do
url "https://github.com/Nimblesite/napper/releases/download/${TAG}/napper-${TAG}-osx-arm64.tar.gz"
sha256 "${SHA256_MACOS_ARM64}"
end
on_intel do
url "https://github.com/Nimblesite/napper/releases/download/${TAG}/napper-${TAG}-osx-x64.tar.gz"
sha256 "${SHA256_MACOS_X64}"
end
end
on_linux do
on_arm do
url "https://github.com/Nimblesite/napper/releases/download/${TAG}/napper-${TAG}-linux-arm64.tar.gz"
sha256 "${SHA256_LINUX_ARM64}"
end
on_intel do
url "https://github.com/Nimblesite/napper/releases/download/${TAG}/napper-${TAG}-linux-x64.tar.gz"
sha256 "${SHA256_LINUX_X64}"
end
end
def install
bin.install "napper"
end
test do
assert_match version.to_s, shell_output("\#{bin}/napper --version")
end
end
FORMULA
cat Formula/napper.rb
- name: Commit and push
shell: bash
run: |
set -euo pipefail
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add Formula/napper.rb
git diff --cached --quiet && { echo "No changes"; exit 0; }
git commit -m "Update napper to ${TAG}"
git push
# ── Scoop bucket: hash from the build's .sha256 sidecar, JSON via a real serializer ──
update-scoop:
name: Update Scoop Manifest
needs: [validate-tag, release]
runs-on: ubuntu-latest
timeout-minutes: 10
env:
TAG: ${{ needs.validate-tag.outputs.tag }}
VERSION: ${{ needs.validate-tag.outputs.version }}
steps:
- name: Checkout scoop-bucket
uses: actions/checkout@v6
with:
repository: Nimblesite/scoop-bucket
token: ${{ secrets.BREW_SCOOP_PAT }}
- uses: actions/setup-node@v6
with:
node-version: 22
- name: Read SHA256 from release sidecar
shell: bash
run: |
set -euo pipefail
BASE="https://github.com/Nimblesite/napper/releases/download/${TAG}"
echo "SHA256=$(curl -fsSL "$BASE/napper-${TAG}-win-x64.zip.sha256" | cut -d ' ' -f 1)" >> "$GITHUB_ENV"
echo "ASSET_URL=$BASE/napper-${TAG}-win-x64.zip" >> "$GITHUB_ENV"
- name: Write manifest
shell: bash
run: |
set -euo pipefail
mkdir -p bucket
node - <<'NODE'
const { mkdirSync, writeFileSync } = require("node:fs");
const manifest = {
version: process.env.VERSION,
description: "CLI-first, test-oriented HTTP API testing tool. Send requests, run assertions, manage environments.",
homepage: "https://napperapi.dev",
license: "MIT",
architecture: { "64bit": { url: process.env.ASSET_URL, hash: process.env.SHA256, bin: "napper.exe" } },
checkver: { github: "https://github.com/Nimblesite/napper" },
autoupdate: {
architecture: {
"64bit": { url: "https://github.com/Nimblesite/napper/releases/download/v$version/napper-v$version-win-x64.zip" }
}
}
};
mkdirSync("bucket", { recursive: true });
writeFileSync("bucket/napper.json", `${JSON.stringify(manifest, null, 2)}\n`);
NODE
cat bucket/napper.json
- name: Commit and push
shell: bash
run: |
set -euo pipefail
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add bucket/napper.json
git diff --cached --quiet && { echo "No changes"; exit 0; }
git commit -m "Update napper to ${TAG}"
git push
# ── Refresh the website after the release assets exist ──
# Ordered after brew/scoop, but gated only on the GitHub Release itself so a
# transient Homebrew/Scoop failure can never skip the production website push.
deploy-website:
name: Deploy Website
needs: [release, update-homebrew, update-scoop]
if: ${{ !cancelled() && needs.release.result == 'success' }}
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
actions: write
steps:
- name: Trigger Pages deploy
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: gh workflow run deploy-pages.yml --repo ${{ github.repository }} --ref main