Docs: Homebrew/Scoop install + VS Code-fork (Open VSX) editor support… #26
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # 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 |