bssh 2.4.3

Parallel SSH command execution tool for cluster management
Documentation
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"