Skip to content

v3.0.0

v3.0.0 #61

Workflow file for this run

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: [prereleased, released]
workflow_dispatch:
inputs:
update_homebrew:
description: 'Update Homebrew formula after build'
required: false
default: 'false'
type: choice
options: ['true','false']
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: [build]
# 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: [build, publish-release]
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: [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"