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
name: Release
on:
# Both ways of cutting a release are supported, and both end with the Homebrew
# tap bumped automatically:
#
# prereleased - the release is published as a pre-release. `build` uploads
# every asset, `publish-release` then flips the release to official, and
# `update-homebrew` bumps the tap.
# released - the release is published directly as official (or an existing
# pre-release is flipped by hand). `publish-release` is skipped because
# there is nothing to convert, so `update-homebrew` keys off `build`.
#
# Before `released` was listed here, a release published directly as official
# started no run at all: no binaries, no assets, no formula bump. Listing it
# cannot loop back on us either, because `publish-release` performs its flip
# with the default GITHUB_TOKEN and events raised by GITHUB_TOKEN do not start
# new workflow runs.
release:
types:
workflow_dispatch:
inputs:
update_homebrew:
description: 'Update Homebrew formula after build'
required: false
default: 'false'
type: choice
options:
release_tag:
description: 'Release tag to upload artifacts to (e.g. v1.2.3)'
required: false
permissions:
contents: write
jobs:
build:
name: Build ${{ matrix.target }}
runs-on: ${{ matrix.os }}
environment: packaging
strategy:
fail-fast: false
matrix:
include:
# Linux x86_64 (glibc)
- target: x86_64-unknown-linux-gnu
os: ubuntu-22.04
artifact_name: bssh
asset_name: bssh-linux-x86_64
archive_ext: ".tar.gz"
# Linux x86_64 (musl - static)
- target: x86_64-unknown-linux-musl
os: ubuntu-latest
artifact_name: bssh
asset_name: bssh-linux-x86_64-musl
archive_ext: ".tar.gz"
# Linux ARM64 (glibc)
- target: aarch64-unknown-linux-gnu
os: ubuntu-22.04-arm
artifact_name: bssh
asset_name: bssh-linux-aarch64
archive_ext: ".tar.gz"
# Linux ARM64 (musl - static)
- target: aarch64-unknown-linux-musl
os: ubuntu-24.04-arm
artifact_name: bssh
asset_name: bssh-linux-aarch64-musl
archive_ext: ".tar.gz"
# macOS ARM64
- target: aarch64-apple-darwin
os: macos-14
artifact_name: bssh
asset_name: bssh-macos-aarch64
asset_suffix: macos-aarch64
archive_ext: ".zip"
steps:
# 1) Checkout repository
- name: Checkout code
uses: actions/checkout@v6
# 2) Cache Cargo build artifacts
- name: Cache cargo
uses: actions/cache@v5
with:
path: |
~/.cargo/registry
~/.cargo/git
target
key: ${{ runner.os }}-cargo-${{ matrix.target }}-${{ hashFiles('**/Cargo.lock') }}
restore-keys: |
${{ runner.os }}-cargo-${{ matrix.target }}-${{ hashFiles('**/Cargo.lock') }}
${{ runner.os }}-cargo-${{ matrix.target }}-
# 3) Install Rust toolchain
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
# 4) Install musl tools only for aarch64 musl builds
- name: Install musl tools (Linux musl only)
if: contains(matrix.target, 'musl')
run: |
sudo apt update
sudo apt install -y musl-tools
# 5) Build release binaries (bssh, bssh-server, and bssh-keygen)
- name: Build release binaries
run: cargo build --release --target ${{ matrix.target }} --locked --bin bssh --bin bssh-server --bin bssh-keygen
# 6) macOS code signing and notarization
#
# Gatekeeper accepts a downloaded binary only when BOTH hold: the code is
# signed by a "Developer ID Application" authority, and Apple has issued a
# notarization ticket for it. Releases up to v2.4.1 satisfied neither.
# They were signed with an "Apple Distribution" certificate, which is an
# App Store submission identity that carries no Developer ID leaf
# extension, and they were never submitted to notarytool. When that
# certificate was later revoked, macOS went from warning to actively
# killing installed binaries on launch and deleting them as malware.
#
# The two composite actions below are mirrored from continuum-router
# (itself mirrored from backend.ai-go), which takes its binaries through
# the same procedure. macos-signing-setup rejects a certificate that is
# not Developer ID Application before anything is signed, and
# macos-sign-notarize-binary asserts the resulting authority and hardened
# runtime flag before it submits, so a wrong certificate now fails the
# release instead of shipping quietly.
- name: Prepare signing certificate and tools
if: runner.os == 'macOS'
uses: ./.github/actions/macos-signing-setup
with:
certificate: ${{ secrets.APPLE_CERTIFICATE }}
certificate-password: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
# Official binaries must never ship unsigned, so a missing or wrong
# certificate is a hard failure.
required: "true"
# 7) Package binaries (separate packages for bssh, bssh-server, and bssh-keygen)
#
# macOS packaging happens inside the signing action, because each zip has
# to be built from the signed copy and then handed to notarytool as a
# single artifact. One call per released binary.
#
# The identifier is passed from BUNDLE_ID (variable preferred over
# secret: it is not sensitive, any user can read it with `codesign -dv`,
# and a secret would be masked to *** in the verification output). With
# BUNDLE_ID=com.lablup.bssh the three binaries seal as com.lablup.bssh,
# com.lablup.bssh-server, and com.lablup.bssh-keygen.
- name: Sign, package, and notarize bssh (macOS)
if: runner.os == 'macOS'
uses: ./.github/actions/macos-sign-notarize-binary
with:
binary-path: target/${{ matrix.target }}/release/bssh
zip-name: ${{ matrix.asset_name }}${{ matrix.archive_ext }}
identifier: ${{ vars.BUNDLE_ID || secrets.BUNDLE_ID }}
extra-files: |
docs/man/bssh.1
api-key-id: ${{ secrets.AC_API_KEY_ID }}
api-issuer-id: ${{ secrets.AC_API_ISSUER_ID }}
api-private-key: ${{ secrets.AC_API_PRIVATE_KEY_P8 }}
- name: Sign, package, and notarize bssh-server (macOS)
if: runner.os == 'macOS'
uses: ./.github/actions/macos-sign-notarize-binary
with:
binary-path: target/${{ matrix.target }}/release/bssh-server
zip-name: bssh-server-${{ matrix.asset_suffix }}${{ matrix.archive_ext }}
identifier: ${{ vars.BUNDLE_ID || secrets.BUNDLE_ID }}-server
extra-files: |
docs/man/bssh-server.8
api-key-id: ${{ secrets.AC_API_KEY_ID }}
api-issuer-id: ${{ secrets.AC_API_ISSUER_ID }}
api-private-key: ${{ secrets.AC_API_PRIVATE_KEY_P8 }}
- name: Sign, package, and notarize bssh-keygen (macOS)
if: runner.os == 'macOS'
uses: ./.github/actions/macos-sign-notarize-binary
with:
binary-path: target/${{ matrix.target }}/release/bssh-keygen
zip-name: bssh-keygen-${{ matrix.asset_suffix }}${{ matrix.archive_ext }}
identifier: ${{ vars.BUNDLE_ID || secrets.BUNDLE_ID }}-keygen
extra-files: |
docs/man/bssh-keygen.1
api-key-id: ${{ secrets.AC_API_KEY_ID }}
api-issuer-id: ${{ secrets.AC_API_ISSUER_ID }}
api-private-key: ${{ secrets.AC_API_PRIVATE_KEY_P8 }}
- name: Package Linux binaries (tar.gz)
if: runner.os == 'Linux'
run: |
BIN_DIR=target/${{ matrix.target }}/release
ASSET_BASE="${{ matrix.asset_name }}"
SERVER_ASSET_BASE="${ASSET_BASE/bssh/bssh-server}"
KEYGEN_ASSET_BASE="${ASSET_BASE/bssh/bssh-keygen}"
# Package bssh
mkdir -p package-bssh
cp "$BIN_DIR/bssh" package-bssh/
cp docs/man/bssh.1 package-bssh/
tar -C package-bssh -czf "${ASSET_BASE}.tar.gz" .
# Package bssh-server
mkdir -p package-bssh-server
cp "$BIN_DIR/bssh-server" package-bssh-server/
cp docs/man/bssh-server.8 package-bssh-server/
tar -C package-bssh-server -czf "${SERVER_ASSET_BASE}.tar.gz" .
# Package bssh-keygen
mkdir -p package-bssh-keygen
cp "$BIN_DIR/bssh-keygen" package-bssh-keygen/
cp docs/man/bssh-keygen.1 package-bssh-keygen/
tar -C package-bssh-keygen -czf "${KEYGEN_ASSET_BASE}.tar.gz" .
# 8) Generate checksums
- name: Generate checksums
run: |
ASSET_BASE="${{ matrix.asset_name }}"
SERVER_ASSET_BASE="${ASSET_BASE/bssh/bssh-server}"
KEYGEN_ASSET_BASE="${ASSET_BASE/bssh/bssh-keygen}"
EXT="${{ matrix.archive_ext }}"
for file in "${ASSET_BASE}${EXT}" "${SERVER_ASSET_BASE}${EXT}" "${KEYGEN_ASSET_BASE}${EXT}"; do
if [[ "$RUNNER_OS" == "Linux" ]]; then
sha256sum "$file" > "$file.sha256"
else
shasum -a 256 "$file" > "$file.sha256"
fi
done
# 9) Upload release artifacts and checksums
- name: Upload release artifacts
if: github.event_name == 'release' || github.event_name == 'workflow_dispatch'
run: |
ASSET_BASE="${{ matrix.asset_name }}"
SERVER_ASSET_BASE="${ASSET_BASE/bssh/bssh-server}"
KEYGEN_ASSET_BASE="${ASSET_BASE/bssh/bssh-keygen}"
EXT="${{ matrix.archive_ext }}"
TAG="${{ github.event.release.tag_name || github.event.inputs.release_tag }}"
gh release upload "$TAG" \
"${ASSET_BASE}${EXT}" \
"${ASSET_BASE}${EXT}.sha256" \
"${SERVER_ASSET_BASE}${EXT}" \
"${SERVER_ASSET_BASE}${EXT}.sha256" \
"${KEYGEN_ASSET_BASE}${EXT}" \
"${KEYGEN_ASSET_BASE}${EXT}.sha256" \
--clobber
env:
GH_TOKEN: ${{ github.token }}
# ============================================================================
# Publish pre-release as official release (after all builds complete)
# ============================================================================
publish-release:
name: Publish pre-release as official
needs:
# A dispatch has to be able to finish a release too, not only build one.
# Rebuilding a tag by hand previously stopped at "artifacts uploaded", so a
# release rescued that way stayed a pre-release, and the Homebrew formula
# job resolves through `releases/latest`, which never returns one.
if: >-
(github.event_name == 'release' && github.event.release.prerelease) ||
(github.event_name == 'workflow_dispatch' && github.event.inputs.release_tag != '')
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Convert pre-release to official release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ github.event.release.tag_name || github.event.inputs.release_tag }}
run: |
set -euo pipefail
if [ -z "$TAG" ]; then
echo "::error::no release tag to promote"
exit 1
fi
# Read the state into a variable rather than testing the command
# substitution inline. A failing `gh` there yields an empty string,
# `set -e` does not fire inside a test, and empty is not "true", so
# the guard would conclude "already promoted" and exit 0 having done
# nothing while reporting success.
IS_PRERELEASE="$(gh release view "$TAG" --json isPrerelease -q .isPrerelease)"
if [ -z "$IS_PRERELEASE" ]; then
echo "::error::could not read the release state for $TAG; refusing to guess whether it needs promoting"
exit 1
fi
# Idempotent: re-running a dispatch against an already-promoted tag
# is a normal thing to do while recovering, and must not fail.
if [ "$IS_PRERELEASE" != "true" ]; then
echo "$TAG is already a full release; nothing to promote"
exit 0
fi
# `--latest` is explicit rather than left to GitHub's own ordering,
# so the tag Homebrew resolves is the one this run promoted.
gh release edit "$TAG" --prerelease=false --latest
# ============================================================================
# Update Homebrew formula (after every release asset has been uploaded)
# ============================================================================
#
# `build` is listed in `needs` explicitly rather than relied on transitively
# through `publish-release`. The called workflow downloads the published
# assets and hashes them, so it must never start before all five build legs
# have finished uploading, and that guarantee has to hold on the path where
# `publish-release` does not run at all. `publish-release` stays in `needs` so
# the pre-release path is still ordered behind the flip to official.
#
# `!cancelled()` is required: `publish-release` is legitimately skipped when
# the release was published as official already, and under the implicit
# `success()` a job whose dependency was skipped is skipped too. A bare
# `always()` would be the wrong way to lift that, since it would also fire
# after a failed build and push a formula whose sha256 values point at assets
# that were never uploaded, so the build result is asserted explicitly instead
# of leaning on job ordering.
#
# On a manual `workflow_dispatch` of this workflow the formula is updated only
# when the `update_homebrew` input asks for it, so rebuilding a tag by hand
# does not push a tap commit as a side effect.
update-homebrew:
name: Update Homebrew formula
needs:
if: >-
!cancelled()
&& needs.build.result == 'success'
&& (needs.publish-release.result == 'success' || needs.publish-release.result == 'skipped')
&& (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.update_homebrew == 'true'))
uses: ./.github/workflows/update_homebrew_formula.yml
with:
release_tag: ${{ github.event.release.tag_name || github.event.inputs.release_tag }}
secrets: inherit
# ============================================================================
# Microsoft Teams release notification (Power Automate Workflows webhook)
# ============================================================================
notify-teams:
name: Notify Teams on release
needs:
if: github.event_name == 'release'
runs-on: ubuntu-latest
permissions:
steps:
- name: Build Adaptive Card payload
env:
TAG: ${{ github.event.release.tag_name }}
NAME: ${{ github.event.release.name }}
URL: ${{ github.event.release.html_url }}
BODY: ${{ github.event.release.body }}
REPO: ${{ github.repository }}
run: |
TRIMMED=$(printf '%s' "$BODY" | head -c 2000)
jq -n \
--arg tag "$TAG" --arg name "$NAME" \
--arg url "$URL" --arg body "$TRIMMED" --arg repo "$REPO" '
{
type: "message",
attachments: [{
contentType: "application/vnd.microsoft.card.adaptive",
content: {
"$schema": "http://adaptivecards.io/schemas/adaptive-card.json",
type: "AdaptiveCard",
version: "1.5",
body: [
{ type: "TextBlock", size: "Large", weight: "Bolder",
text: ("🚀 " + $repo + " " + $tag + " released") },
{ type: "TextBlock", text: $name, wrap: true, isSubtle: true },
{ type: "TextBlock", text: $body, wrap: true }
],
actions: [
{ type: "Action.OpenUrl", title: "View release", url: $url }
]
}
}]
}' > card.json
- name: POST to Teams workflow
if: env.WEBHOOK_URL != ''
env:
WEBHOOK_URL: ${{ secrets.TEAMS_RELEASE_NOTIFICATION_WORKFLOW_URL }}
run: |
curl -sSf -X POST \
-H "Content-Type: application/json" \
--data-binary @card.json \
"$WEBHOOK_URL"