name: Release
on:
release:
types: [published]
workflow_dispatch:
inputs:
update_homebrew:
description: 'Update Homebrew formula after build'
required: false
default: 'false'
type: choice
options: ['true','false']
release_tag:
description: 'Tag to build from AND upload artifacts to (e.g. v1.2.3). Empty = build the dispatched ref.'
required: false
targets:
description: 'Platform families to build (comma-separated: windows, linux, macos, or all). Empty = all. "macos" covers both aarch64 and x86_64.'
required: false
default: ''
type: string
permissions:
contents: write
jobs:
setup:
name: Resolve build matrix
runs-on: ubuntu-latest
outputs:
includes: ${{ steps.resolve.outputs.includes }}
all_assets: ${{ steps.resolve.outputs.all_assets }}
steps:
- name: Resolve target platforms
id: resolve
env:
TARGETS: ${{ github.event_name == 'release' && 'all' || github.event.inputs.targets }}
run: |
set -euo pipefail
# Full build matrix; each entry is tagged with os_family for filtering.
# JSON uses only double quotes, so a single-quoted bash literal is safe
# and avoids heredoc indentation pitfalls inside this YAML block.
#
# `os` becomes the job's runs-on value. It is a label string for every
# GitHub-hosted runner and for the label-targeted Windows box, and a
# {group, labels} object for the self-hosted Intel Mac, which has to be
# selected out of a specific runner group. runs-on is evaluated per
# matrix job instance, so the two shapes coexist without affecting each
# other.
ALL='[
{"os_family":"linux", "target":"x86_64-unknown-linux-gnu", "os":"ubuntu-22.04", "artifact_name":"all-smi", "asset_name":"all-smi-linux-x86_64", "archive_ext":".tar.gz", "protoc_platform":"linux-x86_64"},
{"os_family":"linux", "target":"x86_64-unknown-linux-musl", "os":"ubuntu-latest", "artifact_name":"all-smi", "asset_name":"all-smi-linux-x86_64-musl", "archive_ext":".tar.gz", "protoc_platform":"linux-x86_64"},
{"os_family":"linux", "target":"aarch64-unknown-linux-gnu", "os":"ubuntu-22.04-arm", "artifact_name":"all-smi", "asset_name":"all-smi-linux-aarch64", "archive_ext":".tar.gz", "protoc_platform":"linux-aarch_64"},
{"os_family":"linux", "target":"aarch64-unknown-linux-musl", "os":"ubuntu-24.04-arm", "artifact_name":"all-smi", "asset_name":"all-smi-linux-aarch64-musl", "archive_ext":".tar.gz", "protoc_platform":"linux-aarch_64"},
{"os_family":"macos", "target":"aarch64-apple-darwin", "os":"macos-14", "artifact_name":"all-smi", "asset_name":"all-smi-macos-aarch64", "archive_ext":".zip", "protoc_platform":"osx-aarch_64"},
{"os_family":"macos", "target":"x86_64-apple-darwin", "os":{"group":"macOS x64","labels":"self-hosted-macos-15-x64"}, "artifact_name":"all-smi", "asset_name":"all-smi-macos-x86_64", "archive_ext":".zip", "protoc_platform":"osx-x86_64"},
{"os_family":"windows", "target":"x86_64-pc-windows-msvc", "os":"windows-on-macmini02-x64","artifact_name":"all-smi.exe", "asset_name":"all-smi-windows-x86_64", "archive_ext":".zip", "protoc_platform":""}
]'
# Normalize selection: lowercase, strip spaces; empty => all.
sel="$(printf '%s' "${TARGETS:-}" | tr '[:upper:]' '[:lower:]' | tr -d '[:space:]')"
if [ -z "$sel" ]; then sel="all"; fi
# Validate tokens and expand `all` into concrete families.
fams=""
IFS=','
for tok in $sel; do
case "$tok" in
all) fams="windows linux macos" ;;
windows|linux|macos) fams="$fams $tok" ;;
"") ;;
*) echo "::error::Unknown build target '$tok'. Allowed: windows, linux, macos, all."; exit 1 ;;
esac
done
unset IFS
fam_json="$(printf '%s\n' $fams | awk 'NF' | sort -u | jq -R . | jq -cs .)"
includes="$(printf '%s' "$ALL" | jq -c --argjson fams "$fam_json" \
'[ .[] | select(.os_family as $f | $fams | index($f)) ]')"
if [ "$(printf '%s' "$includes" | jq 'length')" -eq 0 ]; then
echo "::error::No build targets selected."; exit 1
fi
echo "Building families: $fam_json"
printf '%s' "$includes" | jq -r '.[] | " - " + .target'
echo "includes=$includes" >> "$GITHUB_OUTPUT"
# Every asset a complete release carries, from the UNFILTERED matrix.
# `promote-release` needs this: a dispatch may build one family, and
# promoting on the strength of that alone would publish a release
# missing the targets this run never touched.
all_assets="$(printf '%s' "$ALL" | jq -c '[ .[] | .asset_name + .archive_ext ]')"
echo "all_assets=$all_assets" >> "$GITHUB_OUTPUT"
build:
name: Build ${{ matrix.target }}
needs: setup
runs-on: ${{ matrix.os }}
environment: packaging
strategy:
fail-fast: false
matrix:
include: ${{ fromJSON(needs.setup.outputs.includes) }}
env:
BIN_NAME: all-smi
BUNDLE_ID: ${{ vars.BUNDLE_ID }}
PROTOC_VERSION: "31.1"
steps:
- name: Checkout code
uses: actions/checkout@v6
with:
ref: ${{ github.event.release.tag_name || github.event.inputs.release_tag || github.sha }}
- name: Checkout workflow actions
uses: actions/checkout@v6
with:
ref: ${{ github.sha }}
sparse-checkout: .github/actions
sparse-checkout-cone-mode: false
path: .workflow-actions
- name: Setup persistent cache paths (Windows self-hosted)
if: matrix.target == 'x86_64-pc-windows-msvc'
shell: pwsh
run: |
$cargoHome = "C:\.cargo"
if (!(Test-Path $cargoHome)) { New-Item -ItemType Directory -Path $cargoHome -Force | Out-Null }
echo "CARGO_HOME=$cargoHome" >> $env:GITHUB_ENV
echo "RUSTUP_HOME=C:\.rustup" >> $env:GITHUB_ENV
- name: Cache cargo
if: matrix.target != 'x86_64-pc-windows-msvc' && matrix.target != 'x86_64-apple-darwin'
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 }}-
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
- name: Add target architecture
run: rustup target add ${{ matrix.target }}
- name: Install musl tools (Linux musl only)
if: contains(matrix.target, 'musl')
run: |
sudo apt update
sudo apt install -y musl-tools
- name: Install AMD GPU dependencies (Linux only)
if: runner.os == 'Linux'
run: |
sudo apt update
sudo apt install -y libdrm-dev libdrm-amdgpu1
- name: Setup protoc
if: runner.os != 'Windows'
uses: ./.workflow-actions/.github/actions/setup-protoc
with:
version: ${{ env.PROTOC_VERSION }}
platform: ${{ matrix.protoc_platform }}
- name: Build release binary
run: cargo build --release --target ${{ matrix.target }} --locked -p all-smi
- name: Build and verify AMD runtime plugin (Linux glibc only)
if: runner.os == 'Linux' && !contains(matrix.target, 'musl')
run: |
set -euo pipefail
echo "AMD_PLUGIN_BUILT=false" >> "$GITHUB_ENV"
if [ ! -f crates/all-smi-amd-plugin/Cargo.toml ]; then
echo "::notice::The selected source tag predates the AMD companion crate; preserving the self-healing old-tag release path."
exit 0
fi
cargo build --release --target ${{ matrix.target }} --locked -p all-smi-amd-plugin
BIN="target/${{ matrix.target }}/release/${{ matrix.artifact_name }}"
PLUGIN="target/${{ matrix.target }}/release/liball_smi_amd.so"
test -f "$PLUGIN"
if objdump -p "$BIN" | grep -q 'NEEDED.*libdrm'; then
echo "::error::$BIN inherits libdrm even though AMD is runtime-loaded"
exit 1
fi
if ! objdump -p "$PLUGIN" | grep -q 'NEEDED.*libdrm_amdgpu'; then
echo "::error::$PLUGIN does not own the expected libdrm_amdgpu linkage"
exit 1
fi
report="${RUNNER_TEMP}/amd-plugin-doctor.json"
ALL_SMI_AMD_PLUGIN="$PWD/$PLUGIN" "$BIN" doctor --only amd --json > "$report"
cat "$report"
test "$(jq -r '.checks[] | select(.id=="amd.libamdgpu_top.abi") | .status' "$report")" = pass
echo "AMD_PLUGIN_BUILT=true" >> "$GITHUB_ENV"
- name: Prepare signing certificate and tools
if: runner.os == 'macOS'
uses: ./.workflow-actions/.github/actions/macos-signing-setup
with:
certificate: ${{ secrets.DEV_ID_CERT_P12 }}
certificate-password: ${{ secrets.DEV_ID_CERT_PASSWORD }}
required: "true"
- name: Sign macOS binary
if: runner.os == 'macOS'
run: |
set -euo pipefail
BIN="target/${{ matrix.target }}/release/${{ matrix.artifact_name }}"
if [ ! -f "$BIN" ]; then
echo "::error::binary to sign not found: $BIN"
exit 1
fi
mkdir -p package
STAGED="package/${{ matrix.artifact_name }}"
cp "$BIN" "$STAGED"
chmod +x "$STAGED"
# A composite action cannot enforce `required` on its inputs at
# runtime, and an empty identifier would make rcodesign fall back to
# the bare file name. Pin it, or the sealed identifier drifts with
# the artifact name.
if [ -z "${BUNDLE_ID:-}" ]; then
echo "::error::BUNDLE_ID is not set on the packaging environment; set it to a reverse-DNS identifier such as com.lablup.all-smi"
exit 1
fi
# rcodesign requests a secure timestamp from Apple by default, which
# notarization requires. No entitlements: all-smi is a self-contained
# Rust CLI that links no third-party dylib, so the hardened runtime
# applies with Apple's defaults.
rcodesign sign \
--pem-file "$PEM_FILE" \
--code-signature-flags runtime \
--binary-identifier "$BUNDLE_ID" \
"$STAGED"
echo "=== Verifying signature ==="
# `|| true` so a codesign failure surfaces as the named assertions
# below rather than as a bare non-zero exit under `set -e`.
CODESIGN_OUTPUT="$(codesign -dv --verbose=4 "$STAGED" 2>&1 || true)"
echo "$CODESIGN_OUTPUT"
# Matched from here-strings, not `echo | grep`: this runs under
# pipefail, where `grep -q` closing the pipe early can surface as a
# failed pipeline whether or not the pattern matched.
#
# This is the assertion that would have caught bssh's defect.
if ! grep -q "Authority=Developer ID Application" <<<"$CODESIGN_OUTPUT"; then
echo "::error::the binary is not signed by a Developer ID Application authority, so Gatekeeper will refuse it on download"
exit 1
fi
# By flag name, not by a literal hex value: other code signature
# flags combine into the same field.
if ! grep -Eq 'flags=0x[0-9a-f]+\(.*runtime.*\)' <<<"$CODESIGN_OUTPUT"; then
echo "::error::the binary is missing the hardened runtime flag, which notarization requires"
exit 1
fi
if ! grep -qxF "Identifier=$BUNDLE_ID" <<<"$CODESIGN_OUTPUT"; then
echo "::error::the binary was signed with a code signature identifier other than the requested $BUNDLE_ID"
exit 1
fi
echo "Signed as $BUNDLE_ID with a Developer ID Application authority and the hardened runtime"
- name: Notarize macOS binary
if: runner.os == 'macOS'
env:
AC_API_KEY_ID: ${{ secrets.AC_API_KEY_ID }}
AC_API_ISSUER_ID: ${{ secrets.AC_API_ISSUER_ID }}
AC_API_PRIVATE_KEY_P8: ${{ secrets.AC_API_PRIVATE_KEY_P8 }}
run: |
set -euo pipefail
# The staged copy the signing step produced. Notarizing the build
# output in target/ would submit an unsigned binary, which
# notarytool rejects.
BIN="package/${{ matrix.artifact_name }}"
NOTARIZE_ZIP="${RUNNER_TEMP}/notarize.zip"
KEY_FILE="${RUNNER_TEMP}/AuthKey.p8"
# Always wipe the API key file, even on early failure.
cleanup() {
rm -f "$KEY_FILE" "$NOTARIZE_ZIP"
}
trap cleanup EXIT
# The AC_API_PRIVATE_KEY_P8 secret stores the App Store Connect .p8
# key base64-encoded, the same convention DEV_ID_CERT_P12 follows for
# the signing certificate. Decode it back to PEM here. Also tolerate a raw .p8 pasted verbatim, and strip CR so a
# CRLF-mangled paste still parses. Writing the secret verbatim without
# decoding makes notarytool fail with the opaque "Error: invalidAsn1".
if [ -z "${AC_API_PRIVATE_KEY_P8:-}" ]; then
echo "::error::AC_API_PRIVATE_KEY_P8 secret is not set or empty"
exit 1
fi
if printf '%s' "$AC_API_PRIVATE_KEY_P8" | grep -q 'BEGIN PRIVATE KEY'; then
printf '%s\n' "$AC_API_PRIVATE_KEY_P8" | tr -d '\r' > "$KEY_FILE"
else
printf '%s' "$AC_API_PRIVATE_KEY_P8" | tr -d '[:space:]' | base64 --decode > "$KEY_FILE"
fi
chmod 600 "$KEY_FILE"
# Surface a clear message if the materialized key is not a valid
# PKCS#8 key, instead of the opaque notarytool "invalidAsn1" later.
if command -v openssl >/dev/null 2>&1 && ! openssl pkey -in "$KEY_FILE" -noout 2>/dev/null; then
echo "::warning::AC_API_PRIVATE_KEY_P8 did not parse as a PKCS#8 private key after decoding; notarization will likely fail. Re-store it as base64 of the .p8 file (base64 -i AuthKey_XXXX.p8 | pbcopy)."
fi
/usr/bin/ditto -c -k --keepParent "$BIN" "$NOTARIZE_ZIP"
echo "Submitting notarization request..."
SUBMIT_OUTPUT=$(xcrun notarytool submit "$NOTARIZE_ZIP" \
--key "$KEY_FILE" \
--key-id "$AC_API_KEY_ID" \
--issuer "$AC_API_ISSUER_ID" \
--wait --timeout 30m 2>&1) || true
echo "$SUBMIT_OUTPUT"
if echo "$SUBMIT_OUTPUT" | grep -qE 'status: (Invalid|Rejected)'; then
SUBMISSION_ID=$(echo "$SUBMIT_OUTPUT" | awk '/^[[:space:]]*id:/ {print $2; exit}')
if [ -n "${SUBMISSION_ID:-}" ]; then
echo "Notarization failed (submission $SUBMISSION_ID). Fetching log..."
xcrun notarytool log "$SUBMISSION_ID" \
--key "$KEY_FILE" \
--key-id "$AC_API_KEY_ID" \
--issuer "$AC_API_ISSUER_ID" || true
fi
exit 1
fi
if ! echo "$SUBMIT_OUTPUT" | grep -q 'status: Accepted'; then
echo "::error::notarytool did not report an Accepted status"
exit 1
fi
# Gatekeeper verification. The ticket may take a moment to propagate;
# treat a transient failure as a warning rather than a hard error.
if /usr/sbin/spctl --assess --type execute --verbose "$BIN"; then
echo "spctl assessment passed"
else
echo "::warning::spctl assessment did not pass yet; ticket may still be propagating"
fi
- name: Package Linux binary (tar.gz)
if: runner.os == 'Linux'
run: |
set -euo pipefail
BIN_DIR=target/${{ matrix.target }}/release
mkdir -p package
cp "$BIN_DIR/${{ matrix.artifact_name }}" package/
if [ "${AMD_PLUGIN_BUILT:-false}" = true ]; then
cp "$BIN_DIR/liball_smi_amd.so" package/
fi
cp docs/man/all-smi.1 package/
tar -C package -czf ${{ matrix.asset_name }}.tar.gz .
- name: Package macOS binary (zip)
if: runner.os == 'macOS'
run: |
set -euo pipefail
ASSET="${{ matrix.asset_name }}.zip"
STAGED="package/${{ matrix.artifact_name }}"
if [ ! -f "$STAGED" ]; then
echo "::error::signed binary not found at $STAGED; the signing step must run before packaging"
exit 1
fi
# Cheap guard against a future edit reintroducing the overwrite.
#
# `--verbose=4` is required, not cosmetic: plain `codesign -dv`
# prints the identifier, format, and hashes but no `Authority=`
# lines at all, so the grep below could never match and the guard
# failed every run regardless of the signature. Same invocation as
# the assertions in the signing step, for the same reason.
#
# Read into a variable rather than piping into `grep -q`. This step
# runs under pipefail, where grep closing the pipe early can surface
# as a failed pipeline whether or not the pattern matched.
SIGNATURE="$(codesign -dv --verbose=4 "$STAGED" 2>&1 || true)"
if ! grep -q "Authority=Developer ID Application" <<<"$SIGNATURE"; then
echo "::error::$STAGED lost its Developer ID signature between signing and packaging"
echo "$SIGNATURE"
exit 1
fi
cp docs/man/all-smi.1 package/
# ditto preserves the exec bit and the embedded signature.
ditto -c -k --sequesterRsrc package "$ASSET"
- name: Sign Windows binary with signtool
if: runner.os == 'Windows'
shell: pwsh
env:
WINDOWS_SIGN_CERT_PATH: ${{ secrets.WINDOWS_SIGN_CERT_PATH }}
WINDOWS_SIGN_CA_CERT_PATH: ${{ secrets.WINDOWS_SIGN_CA_CERT_PATH }}
WINDOWS_SIGN_KEY_CONTAINER: ${{ secrets.WINDOWS_SIGN_KEY_CONTAINER }}
WINDOWS_SIGNTOOL_PATH: ${{ secrets.WINDOWS_SIGNTOOL_PATH }}
WINDOWS_SIGN_CSP: ${{ secrets.WINDOWS_SIGN_CSP }}
WINDOWS_SIGN_TIMESTAMP_URL: ${{ secrets.WINDOWS_SIGN_TIMESTAMP_URL }}
run: |
./scripts/sign-windows.ps1 "target/${{ matrix.target }}/release/${{ matrix.artifact_name }}"
- name: Package Windows binary (zip)
if: runner.os == 'Windows'
shell: pwsh
run: |
$BIN = "target/${{ matrix.target }}/release/${{ matrix.artifact_name }}"
$ASSET = "${{ matrix.asset_name }}.zip"
New-Item -ItemType Directory -Force -Path package
Copy-Item $BIN -Destination package/
Compress-Archive -Path package/* -DestinationPath $ASSET
- name: Generate checksum (Linux/macOS)
if: runner.os != 'Windows'
run: |
FILE="${{ matrix.asset_name }}${{ matrix.archive_ext }}"
if [[ "$RUNNER_OS" == "Linux" ]]; then
sha256sum "$FILE" > "$FILE.sha256"
else
shasum -a 256 "$FILE" > "$FILE.sha256"
fi
- name: Generate checksum (Windows)
if: runner.os == 'Windows'
shell: pwsh
run: |
$FILE = "${{ matrix.asset_name }}${{ matrix.archive_ext }}"
$hash = (Get-FileHash -Path $FILE -Algorithm SHA256).Hash.ToLower()
"$hash $FILE" | Out-File -FilePath "$FILE.sha256" -Encoding ASCII -NoNewline
- name: Upload release artifacts
if: github.event_name == 'release' || github.event_name == 'workflow_dispatch'
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ github.event.release.tag_name || github.event.inputs.release_tag }}
files: |
${{ matrix.asset_name }}${{ matrix.archive_ext }}
${{ matrix.asset_name }}${{ matrix.archive_ext }}.sha256
notify-teams:
name: Notify Teams on release
needs: build
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"
promote-release:
name: Promote pre-release to release
runs-on: ubuntu-latest
needs: [setup, build]
if: >-
(github.event_name == 'release' && github.event.release.prerelease) ||
(github.event_name == 'workflow_dispatch' && github.event.inputs.release_tag != '')
steps:
- name: Promote to full release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_REPO: ${{ github.repository }}
TAG: ${{ github.event.release.tag_name || github.event.inputs.release_tag }}
ALL_ASSETS: ${{ needs.setup.outputs.all_assets }}
run: |
set -euo pipefail
if [ -z "$TAG" ]; then
echo "::error::no release tag to promote"
exit 1
fi
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
# A dispatch can build one family. Promoting on the strength of that
# would publish a release missing every target this run did not
# touch, and Homebrew would then resolve a version whose artifacts
# are not all there. Check the whole expected set, not just what was
# built here.
PRESENT="$(gh release view "$TAG" --json assets -q '.assets[].name')"
if [ -z "$PRESENT" ]; then
echo "::error::$TAG reports no assets at all; refusing to promote"
exit 1
fi
MISSING=""
while IFS= read -r want; do
[ -n "$want" ] || continue
if ! grep -qxF "$want" <<<"$PRESENT"; then
MISSING="${MISSING:+$MISSING, }$want"
fi
done < <(jq -r '.[]' <<<"$ALL_ASSETS")
if [ -n "$MISSING" ]; then
echo "::error::$TAG is missing release artifacts, so it stays a pre-release: $MISSING"
exit 1
fi
echo "All expected artifacts present on $TAG; promoting"
gh release edit "$TAG" --prerelease=false --latest