1 name: GitHub Release
2
3 on:
4 push:
5 tags:
6 - "v*.*.*"
7 workflow_dispatch:
8 inputs:
9 tag:
10 description: "Release tag (e.g. v0.1.1)"
11 required: true
12
13 concurrency:
14 # A re-pushed or re-dispatched tag fires this a second time. Queue the
15 # duplicate behind the original instead of cancelling it: cancelling a run
16 # mid-publish can leave a channel half-uploaded, whereas a queued duplicate
17 # just hits the "already published" check and exits clean.
18 group: ${{ github.workflow }}-${{ github.event.inputs.tag || github.ref_name }}
19 cancel-in-progress: false
20
21 permissions:
22 contents: write
23 actions: write
24
25 env:
26 PROJECT_NAME: sigit
27 CARGO_TERM_COLOR: always
28
29 jobs:
30 build:
31 name: Build binary (${{ matrix.name }})
32 runs-on: ${{ matrix.runner }}
33
34 strategy:
35 fail-fast: false
36 matrix:
37 include:
38 - name: linux-amd64
39 runner: ubuntu-latest
40 target: x86_64-unknown-linux-gnu
41 - name: linux-arm64
42 runner: ubuntu-24.04-arm
43 target: aarch64-unknown-linux-gnu
44 - name: win-amd64
45 runner: windows-latest
46 target: x86_64-pc-windows-msvc
47 - name: win-arm64
48 runner: windows-latest
49 target: aarch64-pc-windows-msvc
50 - name: macos-amd64
51 runner: macos-26
52 target: x86_64-apple-darwin
53 - name: macos-arm64
54 runner: macos-26
55 target: aarch64-apple-darwin
56
57 steps:
58 - name: Checkout
59 uses: actions/checkout@v6
60 with:
61 ref: ${{ github.event.inputs.tag || github.ref }}
62
63 - name: Set the release version
64 shell: bash
65 run: |
66 if [[ "${GITHUB_REF_TYPE}" == "tag" ]]; then
67 release_version="${GITHUB_REF_NAME#v}"
68 else
69 release_version="${{ github.event.inputs.tag }}"
70 release_version="${release_version#v}"
71 fi
72 echo "RELEASE_VERSION=${release_version}" >> "$GITHUB_ENV"
73
74 - name: Read Rust toolchain
75 shell: bash
76 run: |
77 rust_toolchain="$(sed -n 's/^channel = "\(.*\)"/\1/p' rust-toolchain.toml | head -n 1)"
78 if [ -z "$rust_toolchain" ]; then
79 echo "Failed to read Rust toolchain from rust-toolchain.toml" >&2
80 exit 1
81 fi
82 echo "RUST_TOOLCHAIN=${rust_toolchain}" >> "$GITHUB_ENV"
83
84 - name: Install Rust toolchain
85 uses: dtolnay/rust-toolchain@3c5f7ea28cd621ae0bf5283f0e981fb97b8a7af9
86 with:
87 toolchain: ${{ env.RUST_TOOLCHAIN }}
88 targets: ${{ matrix.target }}
89
90 - name: Setup Rust cache
91 uses: Swatinem/rust-cache@v2
92 with:
93 key: github-${{ matrix.target }}
94
95 - name: Build binary
96 shell: bash
97 run: cargo build --locked --release --target ${{ matrix.target }}
98
99 - name: Check Windows binary has no VC++ runtime dependency
100 if: contains(matrix.target, 'windows')
101 shell: pwsh
102 run: ./packaging/windows/check-static-crt.ps1 -Path target/${{ matrix.target }}/release/${{ env.PROJECT_NAME }}.exe
103
104 - name: Package binary
105 shell: bash
106 run: |
107 BIN_SUFFIX=""
108 if [[ "${{ matrix.runner }}" == "windows-latest" ]]; then
109 BIN_SUFFIX=".exe"
110 fi
111
112 BIN_OUTPUT="target/${{ matrix.target }}/release/${PROJECT_NAME}${BIN_SUFFIX}"
113 BIN_RELEASE="${PROJECT_NAME}-${{ matrix.name }}${BIN_SUFFIX}"
114
115 mkdir -p ./release
116 mv "${BIN_OUTPUT}" "./release/${BIN_RELEASE}"
117
118 - name: Package macOS tarball for Homebrew
119 if: contains(matrix.target, 'apple-darwin')
120 shell: bash
121 run: |
122 ARCHIVE_NAME="${PROJECT_NAME}-${{ matrix.name }}.tar.gz"
123
124 mkdir -p staging-brew
125 cp "./release/${PROJECT_NAME}-${{ matrix.name }}" "staging-brew/${PROJECT_NAME}"
126 strip "staging-brew/${PROJECT_NAME}"
127
128 tar -C staging-brew -czf "${ARCHIVE_NAME}" "${PROJECT_NAME}"
129
130 shasum -a 256 "${ARCHIVE_NAME}" | awk '{print $1}' > "${ARCHIVE_NAME}.sha256"
131
132 echo "Archive: ${ARCHIVE_NAME}"
133 echo "SHA256: $(cat "${ARCHIVE_NAME}.sha256")"
134
135 # Debian and RPM packages are built from the binary staged above rather
136 # than by re-invoking cargo, so each Linux target compiles once and gets
137 # packaged twice.
138 - name: Build Linux packages
139 if: contains(matrix.target, 'linux')
140 shell: bash
141 env:
142 NFPM_VERSION: "2.43.0"
143 run: |
144 case "$(uname -m)" in
145 x86_64) nfpm_arch="x86_64"; export PKG_ARCH="amd64" ;;
146 aarch64) nfpm_arch="arm64"; export PKG_ARCH="arm64" ;;
147 *) echo "Unsupported Linux build host $(uname -m)" >&2; exit 1 ;;
148 esac
149
150 curl -sSfL \
151 "https://github.com/goreleaser/nfpm/releases/download/v${NFPM_VERSION}/nfpm_${NFPM_VERSION}_Linux_${nfpm_arch}.tar.gz" \
152 | tar -xz -C /tmp nfpm
153
154 export PKG_VERSION="${RELEASE_VERSION}"
155 export PKG_BINARY="./release/${PROJECT_NAME}-${{ matrix.name }}"
156
157 # An empty version is worse than a hard failure here: nfpm accepts it
158 # and silently emits a "0.0.0~rc0" package that would be attached to
159 # the release. Same for a missing binary, which only surfaces as an
160 # opaque glob error. Fail loudly instead.
161 if [ -z "${PKG_VERSION}" ]; then
162 echo "RELEASE_VERSION is empty; refusing to build packages" >&2
163 exit 1
164 fi
165 if [ ! -f "${PKG_BINARY}" ]; then
166 echo "Staged binary ${PKG_BINARY} not found" >&2
167 exit 1
168 fi
169
170 # nfpm expands ${VAR} in most fields but NOT in contents.src, so the
171 # config is rendered up front and nfpm only ever sees literal values.
172 # envsubst comes from gettext-base, preinstalled on the Ubuntu runner
173 # images. The explicit variable list keeps it from clobbering any
174 # other shell-looking text added to the config later.
175 if ! command -v envsubst >/dev/null 2>&1; then
176 sudo apt-get update && sudo apt-get install -y gettext-base
177 fi
178
179 envsubst '${PKG_ARCH} ${PKG_VERSION} ${PKG_BINARY}' \
180 < packaging/nfpm.yaml > /tmp/nfpm.rendered.yaml
181
182 /tmp/nfpm package --config /tmp/nfpm.rendered.yaml --packager deb --target ./release/
183 /tmp/nfpm package --config /tmp/nfpm.rendered.yaml --packager rpm --target ./release/
184
185 # Guard against a packager silently producing nothing.
186 for ext in deb rpm; do
187 if ! compgen -G "./release/*.${ext}" >/dev/null; then
188 echo "No .${ext} produced" >&2
189 exit 1
190 fi
191 done
192
193 ls -la ./release/
194
195 # Homebrew has always had a checksum because the tap needs one. Scoop,
196 # winget, and the AUR PKGBUILD each need one too, and they consume the
197 # raw binaries rather than the macOS tarball, so every asset gets a
198 # sidecar. Windows runners use bash from Git for Windows, which has
199 # sha256sum; macOS only has shasum.
200 - name: Checksum release assets
201 shell: bash
202 run: |
203 cd release
204 for asset in *; do
205 case "${asset}" in *.sha256) continue ;; esac
206
207 if command -v sha256sum >/dev/null 2>&1; then
208 sha256sum "${asset}" | awk '{print $1}' > "${asset}.sha256"
209 else
210 shasum -a 256 "${asset}" | awk '{print $1}' > "${asset}.sha256"
211 fi
212
213 echo "${asset}: $(cat "${asset}.sha256")"
214 done
215
216 - name: Upload binary artifact
217 uses: actions/upload-artifact@v4
218 with:
219 name: binary-${{ matrix.name }}
220 path: release/*
221
222 - name: Upload macOS Homebrew artifacts
223 if: contains(matrix.target, 'apple-darwin')
224 uses: actions/upload-artifact@v4
225 with:
226 name: homebrew-${{ matrix.name }}
227 path: |
228 ${{ env.PROJECT_NAME }}-${{ matrix.name }}.tar.gz
229 ${{ env.PROJECT_NAME }}-${{ matrix.name }}.tar.gz.sha256
230
231 release:
232 name: Create GitHub Release
233 needs: build
234 runs-on: ubuntu-latest
235 if: github.ref_type == 'tag' || github.event_name == 'workflow_dispatch'
236
237 steps:
238 - name: Resolve tag
239 id: tag
240 shell: bash
241 run: |
242 if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
243 echo "tag=${{ github.event.inputs.tag }}" >> "$GITHUB_OUTPUT"
244 else
245 echo "tag=${{ github.ref_name }}" >> "$GITHUB_OUTPUT"
246 fi
247
248 - name: Checkout tag
249 # The release job otherwise only downloads artifacts; the changelog
250 # has to be read from the tree at the tag being released.
251 uses: actions/checkout@v6
252 with:
253 ref: ${{ steps.tag.outputs.tag }}
254
255 - name: Compose release notes
256 # The release page carries the section of CHANGELOG.md for this
257 # version, not a bare "Full changelog" default. A missing section is
258 # a hard failure: publishing a release whose notes don't match its
259 # changelog is worse than stopping the job.
260 shell: bash
261 run: |
262 version="${{ steps.tag.outputs.tag }}"
263 version="${version#v}"
264 section="$(awk -v ver="${version}" '
265 $0 == "## " ver { found = 1; next }
266 found && /^## / { exit }
267 found { print }
268 ' CHANGELOG.md)"
269 if [ -z "${section//[$' \t\n']/}" ]; then
270 echo "No ## ${version} section found in CHANGELOG.md" >&2
271 exit 1
272 fi
273 {
274 printf '%s\n' "${section}"
275 printf '\n---\n\n'
276 printf 'Full changelog: https://github.com/%s/blob/%s/CHANGELOG.md\n' \
277 "${GITHUB_REPOSITORY}" "${{ steps.tag.outputs.tag }}"
278 printf '\n---\n\n'
279 printf 'About siGit Code: [sigit.si/code](https://sigit.si/code)\n'
280 } > release_notes.md
281 cat release_notes.md
282
283 - name: Download binary artifacts
284 uses: actions/download-artifact@v4
285 with:
286 pattern: "binary-*"
287 path: release
288 merge-multiple: true
289
290 - name: Download Homebrew artifacts
291 uses: actions/download-artifact@v4
292 with:
293 pattern: "homebrew-*"
294 path: release
295 merge-multiple: true
296
297 - name: Release
298 uses: softprops/action-gh-release@v2
299 with:
300 tag_name: ${{ steps.tag.outputs.tag }}
301 body_path: release_notes.md
302 files: release/*
303
304 # These channels all publish somewhere outside this repo (a tap, a Scoop
305 # bucket, microsoft/winget-pkgs, the AUR) and every one of them reads
306 # checksums off the release created above, so they hang off this job
307 # rather than firing on the tag directly.
308 - name: Trigger OS package manager releases
309 uses: actions/github-script@v7
310 with:
311 script: |
312 const tag = '${{ steps.tag.outputs.tag }}'
313 const workflows = [
314 'release-homebrew.yml',
315 'release-scoop.yml',
316 'release-winget.yml',
317 // New AUR account registration is temporarily closed
318 //'release-aur.yml',
319 ]
320
321 for (const workflow_id of workflows) {
322 try {
323 await github.rest.actions.createWorkflowDispatch({
324 owner: context.repo.owner,
325 repo: context.repo.repo,
326 workflow_id,
327 ref: 'main',
328 inputs: { tag },
329 })
330 console.log(`Dispatched ${workflow_id} for tag ${tag}`)
331 } catch (error) {
332 // One packaging channel being unconfigured (a missing secret,
333 // a bucket repo that does not exist yet) should not take the
334 // rest of the fan-out down with it.
335 core.warning(`Failed to dispatch ${workflow_id}: ${error.message}`)
336 }
337 }