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@3d3c42e5aac5ba805825da76410c181273ba90b1 # 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@3d3c42e5aac5ba805825da76410c181273ba90b1 # 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@3d3c42e5aac5ba805825da76410c181273ba90b1 # 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