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: Package binary
100 shell: bash
101 run: |
102 BIN_SUFFIX=""
103 if [[ "${{ matrix.runner }}" == "windows-latest" ]]; then
104 BIN_SUFFIX=".exe"
105 fi
106
107 BIN_OUTPUT="target/${{ matrix.target }}/release/${PROJECT_NAME}${BIN_SUFFIX}"
108 BIN_RELEASE="${PROJECT_NAME}-${{ matrix.name }}${BIN_SUFFIX}"
109
110 mkdir -p ./release
111 mv "${BIN_OUTPUT}" "./release/${BIN_RELEASE}"
112
113 - name: Package macOS tarball for Homebrew
114 if: contains(matrix.target, 'apple-darwin')
115 shell: bash
116 run: |
117 ARCHIVE_NAME="${PROJECT_NAME}-${{ matrix.name }}.tar.gz"
118
119 mkdir -p staging-brew
120 cp "./release/${PROJECT_NAME}-${{ matrix.name }}" "staging-brew/${PROJECT_NAME}"
121 strip "staging-brew/${PROJECT_NAME}"
122
123 tar -C staging-brew -czf "${ARCHIVE_NAME}" "${PROJECT_NAME}"
124
125 shasum -a 256 "${ARCHIVE_NAME}" | awk '{print $1}' > "${ARCHIVE_NAME}.sha256"
126
127 echo "Archive: ${ARCHIVE_NAME}"
128 echo "SHA256: $(cat "${ARCHIVE_NAME}.sha256")"
129
130 # Debian and RPM packages are built from the binary staged above rather
131 # than by re-invoking cargo, so each Linux target compiles once and gets
132 # packaged twice.
133 - name: Build Linux packages
134 if: contains(matrix.target, 'linux')
135 shell: bash
136 env:
137 NFPM_VERSION: "2.43.0"
138 run: |
139 case "$(uname -m)" in
140 x86_64) nfpm_arch="x86_64"; export PKG_ARCH="amd64" ;;
141 aarch64) nfpm_arch="arm64"; export PKG_ARCH="arm64" ;;
142 *) echo "Unsupported Linux build host $(uname -m)" >&2; exit 1 ;;
143 esac
144
145 curl -sSfL \
146 "https://github.com/goreleaser/nfpm/releases/download/v${NFPM_VERSION}/nfpm_${NFPM_VERSION}_Linux_${nfpm_arch}.tar.gz" \
147 | tar -xz -C /tmp nfpm
148
149 export PKG_VERSION="${RELEASE_VERSION}"
150 export PKG_BINARY="./release/${PROJECT_NAME}-${{ matrix.name }}"
151
152 # An empty version is worse than a hard failure here: nfpm accepts it
153 # and silently emits a "0.0.0~rc0" package that would be attached to
154 # the release. Same for a missing binary, which only surfaces as an
155 # opaque glob error. Fail loudly instead.
156 if [ -z "${PKG_VERSION}" ]; then
157 echo "RELEASE_VERSION is empty; refusing to build packages" >&2
158 exit 1
159 fi
160 if [ ! -f "${PKG_BINARY}" ]; then
161 echo "Staged binary ${PKG_BINARY} not found" >&2
162 exit 1
163 fi
164
165 # nfpm expands ${VAR} in most fields but NOT in contents.src, so the
166 # config is rendered up front and nfpm only ever sees literal values.
167 # envsubst comes from gettext-base, preinstalled on the Ubuntu runner
168 # images. The explicit variable list keeps it from clobbering any
169 # other shell-looking text added to the config later.
170 if ! command -v envsubst >/dev/null 2>&1; then
171 sudo apt-get update && sudo apt-get install -y gettext-base
172 fi
173
174 envsubst '${PKG_ARCH} ${PKG_VERSION} ${PKG_BINARY}' \
175 < packaging/nfpm.yaml > /tmp/nfpm.rendered.yaml
176
177 /tmp/nfpm package --config /tmp/nfpm.rendered.yaml --packager deb --target ./release/
178 /tmp/nfpm package --config /tmp/nfpm.rendered.yaml --packager rpm --target ./release/
179
180 # Guard against a packager silently producing nothing.
181 for ext in deb rpm; do
182 if ! compgen -G "./release/*.${ext}" >/dev/null; then
183 echo "No .${ext} produced" >&2
184 exit 1
185 fi
186 done
187
188 ls -la ./release/
189
190 # Homebrew has always had a checksum because the tap needs one. Scoop,
191 # winget, and the AUR PKGBUILD each need one too, and they consume the
192 # raw binaries rather than the macOS tarball, so every asset gets a
193 # sidecar. Windows runners use bash from Git for Windows, which has
194 # sha256sum; macOS only has shasum.
195 - name: Checksum release assets
196 shell: bash
197 run: |
198 cd release
199 for asset in *; do
200 case "${asset}" in *.sha256) continue ;; esac
201
202 if command -v sha256sum >/dev/null 2>&1; then
203 sha256sum "${asset}" | awk '{print $1}' > "${asset}.sha256"
204 else
205 shasum -a 256 "${asset}" | awk '{print $1}' > "${asset}.sha256"
206 fi
207
208 echo "${asset}: $(cat "${asset}.sha256")"
209 done
210
211 - name: Upload binary artifact
212 uses: actions/upload-artifact@v4
213 with:
214 name: binary-${{ matrix.name }}
215 path: release/*
216
217 - name: Upload macOS Homebrew artifacts
218 if: contains(matrix.target, 'apple-darwin')
219 uses: actions/upload-artifact@v4
220 with:
221 name: homebrew-${{ matrix.name }}
222 path: |
223 ${{ env.PROJECT_NAME }}-${{ matrix.name }}.tar.gz
224 ${{ env.PROJECT_NAME }}-${{ matrix.name }}.tar.gz.sha256
225
226 release:
227 name: Create GitHub Release
228 needs: build
229 runs-on: ubuntu-latest
230 if: github.ref_type == 'tag' || github.event_name == 'workflow_dispatch'
231
232 steps:
233 - name: Resolve tag
234 id: tag
235 shell: bash
236 run: |
237 if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
238 echo "tag=${{ github.event.inputs.tag }}" >> "$GITHUB_OUTPUT"
239 else
240 echo "tag=${{ github.ref_name }}" >> "$GITHUB_OUTPUT"
241 fi
242
243 - name: Checkout tag
244 # The release job otherwise only downloads artifacts; the changelog
245 # has to be read from the tree at the tag being released.
246 uses: actions/checkout@v6
247 with:
248 ref: ${{ steps.tag.outputs.tag }}
249
250 - name: Compose release notes
251 # The release page carries the section of CHANGELOG.md for this
252 # version, not a bare "Full changelog" default. A missing section is
253 # a hard failure: publishing a release whose notes don't match its
254 # changelog is worse than stopping the job.
255 shell: bash
256 run: |
257 version="${{ steps.tag.outputs.tag }}"
258 version="${version#v}"
259 section="$(awk -v ver="${version}" '
260 $0 == "## " ver { found = 1; next }
261 found && /^## / { exit }
262 found { print }
263 ' CHANGELOG.md)"
264 if [ -z "${section//[$' \t\n']/}" ]; then
265 echo "No ## ${version} section found in CHANGELOG.md" >&2
266 exit 1
267 fi
268 {
269 printf '%s\n' "${section}"
270 printf '\n---\n\n'
271 printf 'Full changelog: https://github.com/%s/blob/%s/CHANGELOG.md\n' \
272 "${GITHUB_REPOSITORY}" "${{ steps.tag.outputs.tag }}"
273 printf '\n---\n\n'
274 printf 'About siGit Code: [sigit.si/code](https://sigit.si/code)\n'
275 } > release_notes.md
276 cat release_notes.md
277
278 - name: Download binary artifacts
279 uses: actions/download-artifact@v4
280 with:
281 pattern: "binary-*"
282 path: release
283 merge-multiple: true
284
285 - name: Download Homebrew artifacts
286 uses: actions/download-artifact@v4
287 with:
288 pattern: "homebrew-*"
289 path: release
290 merge-multiple: true
291
292 - name: Release
293 uses: softprops/action-gh-release@v2
294 with:
295 tag_name: ${{ steps.tag.outputs.tag }}
296 body_path: release_notes.md
297 files: release/*
298
299 # These channels all publish somewhere outside this repo (a tap, a Scoop
300 # bucket, microsoft/winget-pkgs, the AUR) and every one of them reads
301 # checksums off the release created above, so they hang off this job
302 # rather than firing on the tag directly.
303 - name: Trigger OS package manager releases
304 uses: actions/github-script@v7
305 with:
306 script: |
307 const tag = '${{ steps.tag.outputs.tag }}'
308 const workflows = [
309 'release-homebrew.yml',
310 'release-scoop.yml',
311 'release-winget.yml',
312 // New AUR account registration is temporarily closed
313 //'release-aur.yml',
314 ]
315
316 for (const workflow_id of workflows) {
317 try {
318 await github.rest.actions.createWorkflowDispatch({
319 owner: context.repo.owner,
320 repo: context.repo.repo,
321 workflow_id,
322 ref: 'main',
323 inputs: { tag },
324 })
325 console.log(`Dispatched ${workflow_id} for tag ${tag}`)
326 } catch (error) {
327 // One packaging channel being unconfigured (a missing secret,
328 // a bucket repo that does not exist yet) should not take the
329 // rest of the fan-out down with it.
330 core.warning(`Failed to dispatch ${workflow_id}: ${error.message}`)
331 }
332 }