gvsn 1.0.0

A fast, cross-platform Go version manager written in Rust
Documentation
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
name: Release

# Triggered by pushing a version tag - either manually or by the auto-release
# job in ci.yml. Builds compressed archives for all supported platforms, embeds
# the tag version into each binary at compile time, generates checksums + SBOM,
# creates the GitHub Release with structured notes and uploads everything.
on:
  push:
    tags:
      - "v[0-9]+.[0-9]+.[0-9]+"
  # Allows the auto-release job in ci.yml to dispatch this workflow explicitly
  # after pushing a tag with GITHUB_TOKEN, which does not trigger push events.
  workflow_dispatch:

permissions:
  contents: read

env:
  CARGO_TERM_COLOR: always
  FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"

jobs:
  # ── Cross-compile for every supported target ──────────────────────────────────
  #
  # Naming convention (must stay in sync with upgrade.rs::release_archive_name()):
  #   Archive : gvsn_{os}_{arch}.tar.gz  - Linux / macOS
  #   Archive : gvsn_{os}_{arch}.zip     - Windows
  build:
    name: Build ${{ matrix.artifact_archive }}
    runs-on: ${{ matrix.os }}
    permissions:
      contents: read
    strategy:
      fail-fast: false
      matrix:
        include:
          # ── Linux x86_64 - 100% static musl binary ────────────────────────────
          - os: ubuntu-latest
            target: x86_64-unknown-linux-musl
            binary_name: gvsn
            artifact_archive: gvsn_linux_x86_64.tar.gz
            archive_type: tar
            use_cross: false

          # ── Linux aarch64 - static musl (cross + Docker) ─────────────────────
          - os: ubuntu-latest
            target: aarch64-unknown-linux-musl
            binary_name: gvsn
            artifact_archive: gvsn_linux_aarch64.tar.gz
            archive_type: tar
            use_cross: true

          # ── Android aarch64 - bionic libc (Termux) ───────────────────────────
          - os: ubuntu-latest
            target: aarch64-linux-android
            binary_name: gvsn
            artifact_archive: gvsn_android_aarch64.tar.gz
            archive_type: tar
            use_cross: true

          # ── Linux armv7 - static musl (cross + Docker) ───────────────────────
          - os: ubuntu-latest
            target: armv7-unknown-linux-musleabihf
            binary_name: gvsn
            artifact_archive: gvsn_linux_armv7.tar.gz
            archive_type: tar
            use_cross: true

          # ── Linux 386 - static musl (cross + Docker) ─────────────────────────
          - os: ubuntu-latest
            target: i686-unknown-linux-musl
            binary_name: gvsn
            artifact_archive: gvsn_linux_386.tar.gz
            archive_type: tar
            use_cross: true

          # ── Linux riscv64 - gnu (no stable musl target) ───────────────────────
          - os: ubuntu-latest
            target: riscv64gc-unknown-linux-gnu
            binary_name: gvsn
            artifact_archive: gvsn_linux_riscv64.tar.gz
            archive_type: tar
            use_cross: true

          # ── Linux s390x - gnu (IBM Z mainframe) ───────────────────────────────
          - os: ubuntu-latest
            target: s390x-unknown-linux-gnu
            binary_name: gvsn
            artifact_archive: gvsn_linux_s390x.tar.gz
            archive_type: tar
            use_cross: true

          # ── Linux ppc64le - gnu (IBM POWER LE) ───────────────────────────────
          - os: ubuntu-latest
            target: powerpc64le-unknown-linux-gnu
            binary_name: gvsn
            artifact_archive: gvsn_linux_ppc64le.tar.gz
            archive_type: tar
            use_cross: true

          # ── macOS x86_64 (Intel) ──────────────────────────────────────────────
          - os: macos-latest
            target: x86_64-apple-darwin
            binary_name: gvsn
            artifact_archive: gvsn_darwin_x86_64.tar.gz
            archive_type: tar
            use_cross: false

          # ── macOS aarch64 (Apple Silicon) ─────────────────────────────────────
          - os: macos-latest
            target: aarch64-apple-darwin
            binary_name: gvsn
            artifact_archive: gvsn_darwin_aarch64.tar.gz
            archive_type: tar
            use_cross: false

          # ── Windows x86_64 (MSVC) ─────────────────────────────────────────────
          - os: windows-latest
            target: x86_64-pc-windows-msvc
            binary_name: gvsn.exe
            artifact_archive: gvsn_windows_x86_64.zip
            archive_type: zip
            use_cross: false

          # ── Windows arm64 (MSVC cross-compile) ───────────────────────────────
          - os: windows-latest
            target: aarch64-pc-windows-msvc
            binary_name: gvsn.exe
            artifact_archive: gvsn_windows_arm64.zip
            archive_type: zip
            use_cross: false

    steps:
      - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v4

      - name: Install Rust stable
        uses: dtolnay/rust-toolchain@stable
        with:
          targets: ${{ matrix.target }}

      - name: Cache Cargo registry and build artifacts
        uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2
        with:
          key: release-${{ matrix.target }}

      # ── Embed the tag version into Cargo.toml before compiling ───────────────
      #
      # The tag (e.g. v1.1.0) is stripped of its leading 'v' and written into
      # Cargo.toml and Cargo.lock so that `gvsn --version` and the upgrade check
      # report the correct version without committing anything to main.
      #
      # sed note: macOS uses BSD sed which does not support GNU-only 0,/pat/
      # address ranges. The simple s/^version = .../ pattern is portable because
      # only the [package] version line starts at column 0 in our Cargo.toml.
      # Cargo.lock needs a two-line match so perl is used there instead.
      - name: Set version from tag
        shell: bash
        run: |
          VERSION="${GITHUB_REF_NAME#v}"
          # Cargo.toml - simple substitution, no GNU-only address ranges
          sed -i.bak 's/^version = "[^"]*"/version = "'"${VERSION}"'"/' Cargo.toml
          rm -f Cargo.toml.bak
          # Cargo.lock - stateful perl: set flag on name = "gvsn", replace version on next line.
          # \r? makes the pattern work on both LF (Linux/macOS) and CRLF (Windows checkout).
          perl -i.bak -pe 'if (/^name = "gvsn"\r?$/) { $f=1 } elsif ($f) { s/^version = "[^"]*"/version = "'"${VERSION}"'"/; $f=0 }' Cargo.lock
          rm -f Cargo.lock.bak
          echo "Version set to ${VERSION}"
          grep '^version' Cargo.toml

      # ── Linux-only toolchain setup ────────────────────────────────────────────

      - name: Install musl-tools (Linux x86_64 static)
        if: matrix.target == 'x86_64-unknown-linux-musl'
        run: sudo apt-get update -q && sudo apt-get install -y musl-tools

      - name: Install cross (Linux targets via Docker)
        if: matrix.use_cross
        run: cargo install cross --locked

      # ── Compile ───────────────────────────────────────────────────────────────

      - name: Build binary (cross - Docker QEMU)
        if: matrix.use_cross
        run: cross build --release --locked --target ${{ matrix.target }}

      - name: Build binary (cargo - native)
        if: "!matrix.use_cross"
        run: cargo build --release --locked --target ${{ matrix.target }}

      # ── Package compressed archive ────────────────────────────────────────────

      - name: Package archive (tar.gz - Linux / macOS)
        if: matrix.archive_type == 'tar'
        shell: bash
        run: |
          cp "target/${{ matrix.target }}/release/${{ matrix.binary_name }}" gvsn
          tar czf "${{ matrix.artifact_archive }}" gvsn
          rm gvsn

      - name: Package archive (zip - Windows)
        if: matrix.archive_type == 'zip'
        shell: pwsh
        run: |
          Copy-Item "target\${{ matrix.target }}\release\${{ matrix.binary_name }}" gvsn.exe
          Compress-Archive -Path gvsn.exe `
                           -DestinationPath "${{ matrix.artifact_archive }}" `
                           -Force
          Remove-Item gvsn.exe

      # ── Code signing (not wired in yet - see SECURITY.md) ─────────────────────
      #
      # Windows archives ship unsigned for now. gvsn has applied to OSSign
      # (https://ossign.org), a free code-signing service for open-source
      # projects, but their applications are currently suspended due to
      # backlog - see SECURITY.md for status and what activating this will
      # involve once approved.

      # ── Upload to workflow artifacts (collected by publish job) ───────────────

      - name: Upload archive
        uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
        with:
          name: ${{ matrix.artifact_archive }}
          path: ${{ matrix.artifact_archive }}
          if-no-files-found: error
          retention-days: 1

  # ── Checksums · SBOM · Create Release · Upload Assets ────────────────────────
  publish:
    name: Publish Release Assets
    needs: build
    runs-on: ubuntu-latest
    permissions:
      contents: write
      id-token: write
    # secrets.* cannot be referenced directly in `if:` conditionals (GitHub
    # rejects the whole workflow file at parse time) - route it through env
    # first, which is one of the few places secrets are actually allowed.
    env:
      VIRUSTOTAL_CONFIGURED: ${{ secrets.VIRUSTOTAL_API_KEY != '' }}

    steps:
      - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v4
        with:
          fetch-depth: 0

      - name: Download all build artifacts
        uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
        with:
          path: dist/
          merge-multiple: true

      - name: List dist/ contents
        run: ls -lh dist/

      # ── checksums.txt ──────────────────────────────────────────────────────────

      - name: Generate checksums.txt
        working-directory: dist
        run: |
          sha256sum gvsn_* > checksums.txt
          echo ""
          echo "=== checksums.txt ==="
          cat checksums.txt

      # ── SBOM ──────────────────────────────────────────────────────────────────

      - name: Generate SBOM - CycloneDX JSON
        uses: anchore/sbom-action@e22c389904149dbc22b58101806040fa8d37a610 # v0.24.0
        with:
          path: .
          format: cyclonedx-json
          output-file: dist/sbom.cyclonedx.json
          upload-artifact: false
          upload-release-assets: false

      - name: Generate SBOM - SPDX JSON
        uses: anchore/sbom-action@e22c389904149dbc22b58101806040fa8d37a610 # v0.24.0
        with:
          path: .
          format: spdx-json
          output-file: dist/sbom.spdx.json
          upload-artifact: false
          upload-release-assets: false

      # ── Generate release notes from conventional commits ──────────────────────

      - name: Generate release notes
        id: notes
        run: |
          set -euo pipefail

          TAG="${{ github.ref_name }}"
          REPO="${{ github.repository }}"

          PREV_TAG=$(git tag -l 'v[0-9]*.[0-9]*.[0-9]*' | sort -V | grep -v "^${TAG}$" | tail -1 || true)
          [ -z "$PREV_TAG" ] && RANGE="HEAD" || RANGE="${PREV_TAG}..HEAD"

          {
            echo "## What's Changed in ${TAG}"
            echo ""

            BREAKING=$(git log "$RANGE" --format="%s" | grep -E '^[a-z]+(\([^)]*\))?!:' | sed 's/^[^:]*: //' || true)
            if [ -n "$BREAKING" ]; then
              echo "### Breaking Changes"
              while IFS= read -r l; do [ -n "$l" ] && echo "- $l"; done <<< "$BREAKING"
              echo ""
            fi

            FEATS=$(git log "$RANGE" --format="%s" | grep -E '^feat(\([^)]*\))?:' | sed 's/^feat[^:]*: //' || true)
            if [ -n "$FEATS" ]; then
              echo "### New Features"
              while IFS= read -r l; do [ -n "$l" ] && echo "- $l"; done <<< "$FEATS"
              echo ""
            fi

            FIXES=$(git log "$RANGE" --format="%s" | grep -E '^fix(\([^)]*\))?:' | sed 's/^fix[^:]*: //' || true)
            if [ -n "$FIXES" ]; then
              echo "### Bug Fixes"
              while IFS= read -r l; do [ -n "$l" ] && echo "- $l"; done <<< "$FIXES"
              echo ""
            fi

            REFACTORS=$(git log "$RANGE" --format="%s" | grep -E '^refactor(\([^)]*\))?:' | sed 's/^refactor[^:]*: //' || true)
            if [ -n "$REFACTORS" ]; then
              echo "### Refactoring"
              while IFS= read -r l; do [ -n "$l" ] && echo "- $l"; done <<< "$REFACTORS"
              echo ""
            fi

            SECURITY=$(git log "$RANGE" --format="%s" | grep -E '^security(\([^)]*\))?:' | sed 's/^security[^:]*: //' || true)
            if [ -n "$SECURITY" ]; then
              echo "### Security"
              while IFS= read -r l; do [ -n "$l" ] && echo "- $l"; done <<< "$SECURITY"
              echo ""
            fi

            PERF=$(git log "$RANGE" --format="%s" | grep -E '^perf(\([^)]*\))?:' | sed 's/^perf[^:]*: //' || true)
            if [ -n "$PERF" ]; then
              echo "### Performance"
              while IFS= read -r l; do [ -n "$l" ] && echo "- $l"; done <<< "$PERF"
              echo ""
            fi

            if [ -n "$PREV_TAG" ]; then
              echo "---"
              echo "**Full changelog**: https://github.com/${REPO}/compare/${PREV_TAG}...${TAG}"
              echo ""
            fi

            echo "### Install or upgrade"
            echo ""
            echo "\`\`\`powershell"
            echo "# Windows"
            echo "irm https://raw.githubusercontent.com/${REPO}/main/install/install.ps1 | iex"
            echo ""
            echo "# Or upgrade in-place"
            echo "gvsn upgrade"
            echo "\`\`\`"
            echo ""
            echo "\`\`\`sh"
            echo "# Linux / macOS"
            echo "curl -fsSL https://raw.githubusercontent.com/${REPO}/main/install/install.sh | sh"
            echo ""
            echo "# Or upgrade in-place"
            echo "gvsn upgrade"
            echo "\`\`\`"
          } > /tmp/release_notes.md

          cat /tmp/release_notes.md

      # ── Create GitHub Release and upload all assets ───────────────────────────

      - name: Create GitHub Release and upload assets
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        run: |
          TAG="${{ github.ref_name }}"

          # Idempotent: this workflow can be re-dispatched against an existing tag
          # (e.g. to retry a failed step further down the pipeline), in which case
          # the release already exists and must be updated instead of created.
          if gh release view "$TAG" --repo "${{ github.repository }}" >/dev/null 2>&1; then
            echo "Release ${TAG} already exists - updating it instead of creating"
            gh release edit "$TAG" \
              --repo "${{ github.repository }}" \
              --title "gvsn ${TAG}" \
              --notes-file /tmp/release_notes.md \
              --latest
          else
            gh release create "$TAG" \
              --repo "${{ github.repository }}" \
              --title "gvsn ${TAG}" \
              --notes-file /tmp/release_notes.md \
              --latest \
              --verify-tag
          fi

          echo "Uploading assets for ${TAG} ..."
          for file in dist/*; do
            echo "  - $(basename "$file")"
            gh release upload "$TAG" "$file" \
              --repo "${{ github.repository }}" \
              --clobber
          done

          echo "Release ${TAG} published with all assets"

      # ── VirusTotal scan (optional, needs a VIRUSTOTAL_API_KEY secret) ─────────
      #
      # Public transparency and a paper trail for antivirus/SmartScreen false
      # positives: submits every release archive to VirusTotal and appends the
      # per-file analysis link to the release notes. Skipped entirely (no
      # failure, no step even runs) when the secret isn't configured - e.g. on
      # forks. Get a free API key at https://www.virustotal.com/gui/join-us
      # and add it as the VIRUSTOTAL_API_KEY repository secret to enable this.
      # request_rate matches the public API's free-tier limit (4/minute).
      - name: Scan release archives with VirusTotal
        if: ${{ env.VIRUSTOTAL_CONFIGURED == 'true' }}
        uses: crazy-max/ghaction-virustotal@936d8c5c00afe97d3d9a1af26d017cfdf26800a2 # v5.0.0
        with:
          vt_api_key: ${{ secrets.VIRUSTOTAL_API_KEY }}
          update_release_body: true
          request_rate: 4
          files: |
            dist/gvsn_*

      # ── Workflow job summary ───────────────────────────────────────────────────

      - name: Job summary
        run: |
          TAG="${{ github.ref_name }}"
          {
            echo "## Release Assets - ${TAG}"
            echo ""
            echo "| File | Size | Purpose |"
            echo "|------|------|---------|"
            for f in dist/gvsn_*; do
              [ -f "$f" ] || continue
              printf "| \`%s\` | %s | install archive |\n" \
                "$(basename "$f")" "$(du -h "$f" | cut -f1)"
            done
            echo "| \`checksums.txt\` | - | SHA-256 integrity verification |"
            echo "| \`sbom.cyclonedx.json\` | - | SBOM (CycloneDX) |"
            echo "| \`sbom.spdx.json\` | - | SBOM (SPDX ISO/IEC 5962:2021) |"
            echo ""
            echo "### Checksums"
            echo "\`\`\`"
            cat dist/checksums.txt
            echo "\`\`\`"
            echo ""
            if [ -n "${{ secrets.VIRUSTOTAL_API_KEY }}" ]; then
              echo "### VirusTotal"
              echo "Per-file scan links were appended to the [release notes](https://github.com/${{ github.repository }}/releases/tag/${TAG})."
            else
              echo "### VirusTotal"
              echo "Skipped - no \`VIRUSTOTAL_API_KEY\` repository secret configured."
            fi
          } >> "$GITHUB_STEP_SUMMARY"

  # ── Publish to crates.io ──────────────────────────────────────────────────────
  #
  # Runs after the GitHub Release is published so crates.io always reflects a
  # version that has already passed the full build/test/release pipeline.
  # `cargo publish` is idempotent-safe here: if this exact version is already on
  # crates.io (e.g. workflow re-run), the step fails without side effects.
  publish-crate:
    name: Publish to crates.io
    needs: publish
    runs-on: ubuntu-latest
    permissions:
      contents: read

    steps:
      - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v4

      - name: Install Rust stable
        run: rustup default stable

      # This project's release version comes entirely from the git tag (see the
      # auto-tag-and-release job in ci.yml) - Cargo.toml's `version` field is not
      # bumped as part of that process. cargo publish, unlike the GitHub release
      # pipeline, has no concept of git tags: it always publishes whatever version
      # is in Cargo.toml. Sync it from the tag here, for this ephemeral checkout
      # only - nothing is committed back to the repository.
      - name: Sync Cargo.toml version from tag
        run: |
          TAG="${{ github.ref_name }}"
          TAG_VERSION="${TAG#v}"
          echo "Setting Cargo.toml version to ${TAG_VERSION} (from tag ${TAG})"
          sed -i "0,/^version = \".*\"/s//version = \"${TAG_VERSION}\"/" Cargo.toml
          grep -m1 '^version' Cargo.toml

      - name: cargo publish
        env:
          CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
        # --allow-dirty: the version sync above intentionally modifies Cargo.toml
        # without committing (see comment above) - cargo publish otherwise
        # refuses to package a working directory with uncommitted changes.
        run: cargo publish --allow-dirty