Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
226 changes: 226 additions & 0 deletions .github/workflows/buzz-cli-release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,226 @@
name: buzz-cli release

# Cuts the Linux x86-64 `buzz` and `buzz-pair` binaries that customer boxes
# install as /usr/local/bin/buzz and /usr/local/bin/buzz-pair (DIVE-3512).
#
# WHY THIS EXISTS RATHER THAN release.yml: upstream's release rail is the
# desktop app — it triggers on `desktop-v*` tags, builds a Tauri bundle on
# macOS, and is guarded by `if: github.repository == 'block/buzz'`, so it
# neither runs here nor produces a server-side CLI. This is a separate, much
# smaller rail with the same guard pointed at our own repository, so a
# re-fork of this repo cannot fire it by accident.
#
# WHY ubuntu-22.04 AND NOT ubuntu-latest: the artifact is dynamically linked
# against glibc, and a binary built on 24.04 (glibc 2.39) will not start on a
# 22.04 box. Building on the older image means the artifact runs on both.
#
# The build is the provenance: every run records the commit it built, and the
# BuildID and sha256 of what came out. An artifact without that trail must not
# be installed on a customer box.

on:
# Building from the branch is how this workflow gets exercised before it is on
# main: workflow_dispatch only resolves against the default branch, so without
# this the recipe could not be run until after it was merged, which is the
# wrong order to find out it is broken.
push:
branches:
- dive-3512-buzz-cli-release
workflow_dispatch:
inputs:
release_tag:
description: >-
Tag to publish the binaries under (e.g. cli-v0.1.0). Leave empty to
build and attach artifacts to the run without cutting a release.
required: false
type: string

permissions:
contents: read

jobs:
build:
name: Build buzz-cli (linux x86-64)
# Same shape as the guard upstream puts on its own release jobs: this must
# not fire in anybody else's fork of this fork.
if: github.repository == '5dive-ai/buzz'
runs-on: ubuntu-22.04
timeout-minutes: 60
permissions:
contents: write # cutting the release and uploading its assets
defaults:
run:
shell: bash

steps:
- name: Install system dependencies
env:
DEBIAN_FRONTEND: noninteractive
run: |
sudo apt-get update \
-o Acquire::Retries=3 \
-o Acquire::http::Timeout=30
sudo apt-get install -y --no-install-recommends \
-o Acquire::Retries=3 \
-o DPkg::Lock::Timeout=120 \
build-essential \
pkg-config \
libssl-dev \
ca-certificates

- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false

# No setup-rust step: rust-toolchain.toml pins 1.95.0 and the runner's
# preinstalled rustup honors it on the first cargo invocation.
- name: Record toolchain
run: |
rustup show active-toolchain
cargo --version
rustc --version

- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
with:
key: buzz-cli-release

- name: Build
run: cargo build --release --locked -p buzz-cli -p buzz-pairing-cli

- name: Record provenance
id: prov
run: |
set -euo pipefail
mkdir -p dist
cp target/release/buzz dist/buzz
cp target/release/buzz-pair dist/buzz-pair

{
echo "repository: ${GITHUB_REPOSITORY}"
echo "commit: ${GITHUB_SHA}"
echo "workflow_run: ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}"
echo "built_on: $(. /etc/os-release && echo "$PRETTY_NAME") / glibc $(ldd --version | head -1 | awk '{print $NF}')"
echo "toolchain: $(rustc --version)"
echo
for b in buzz buzz-pair; do
echo "== ${b} =="
echo "size: $(stat -c%s "dist/${b}") bytes"
echo "sha256: $(sha256sum "dist/${b}" | cut -d' ' -f1)"
echo "buildid: $(readelf -n "dist/${b}" | awk '/Build ID/ {print $3}')"
echo "file: $(file -b "dist/${b}")"
echo
done
} | tee dist/PROVENANCE.txt

( cd dist && sha256sum buzz buzz-pair > SHA256SUMS )

- name: Smoke the artifact
run: |
set -euo pipefail
# NOTE: `buzz` has no --version. Its clap command sets no `version`
# attribute, so the flag is an error: {"error":"user_error",
# "message":"unexpected argument '--version' found"}. Do not add one
# here to make a smoke pass — the binary's identity on a box is its
# BuildID, which readelf reads straight out of the file and which
# this release records. That is a stronger answer than a version
# string the workspace stamps 0.1.0 on everything anyway.
./dist/buzz --help > /dev/null
./dist/buzz-pair --help > /dev/null

# The binary must actually DISPATCH, not merely parse. Assert the
# CLI's own documented error contract -- "Errors are JSON on stderr:
# {\"error\": <category>, \"message\": <detail>}" -- rather than a
# specific exit code, because the code depends on how far the call
# gets: with no key it is 1 (bad input) and never reaches the
# network. Both stderr payloads are printed so a future reader can
# see what was actually observed instead of trusting this comment.
#
# The panic check is the one that earns its place: the defect our own
# pairing patch fixed was a rustls CryptoProvider PANIC on wss://, and
# a panic is precisely what does NOT produce this JSON contract. This
# step is the regression guard for that class.
check_contract() { # <label> <expected-rc-or-empty> <cmd...>
local label="$1" want="$2"; shift 2
local err rc
err=$("$@" 2>&1 >/dev/null) && rc=0 || rc=$?
echo " ${label}: rc=${rc} stderr=${err}"
if grep -q 'panicked at' <<<"$err"; then
echo "::error::${label}: the binary PANICKED; that is the failure mode the pairing fix exists to prevent"
exit 1
fi
[[ "$rc" != "0" ]] || { echo "::error::${label}: expected a non-zero exit"; exit 1; }
jq -e 'has("error") and has("message")' <<<"$err" >/dev/null \
|| { echo "::error::${label}: stderr is not the documented {error,message} JSON"; exit 1; }
# NON-VACUITY GUARD. The first version of this step passed while
# both cases died on `unrecognized subcommand 'channel'` -- a clap
# usage error satisfies the JSON contract without the CLI ever
# dispatching anything, so the step grades nothing. A usage error
# here means the smoke is broken, not the binary.
if jq -re '.message' <<<"$err" | grep -qE 'unrecognized subcommand|^Usage:|unexpected argument'; then
echo "::error::${label}: this is a clap USAGE error, so the check never reached the CLI. Fix the smoke."
exit 1
fi
if [[ -n "$want" && "$rc" != "$want" ]]; then
echo "::error::${label}: expected exit ${want}, got ${rc}"; exit 1
fi
}

echo "CLI error-contract checks:"
# No identity configured -> 3. Note this is auth_error, NOT the 1
# ('bad input') the exit-code table might suggest for a missing
# argument: the CLI classifies an absent BUZZ_PRIVATE_KEY as an auth
# failure. Pinned at the value actually observed, not the one the
# documentation reads like -- and pinned deliberately, because a
# silent change here would change what every caller branches on.
check_contract "no private key" 3 \
env -u BUZZ_PRIVATE_KEY BUZZ_RELAY_URL=http://127.0.0.1:1 ./dist/buzz channels list
# Identity present, relay unreachable -> it must reach the network and
# fail there. Key is a throwaway constant, never a real identity.
check_contract "unreachable relay" "" \
env BUZZ_PRIVATE_KEY=0000000000000000000000000000000000000000000000000000000000000001 \
BUZZ_RELAY_URL=http://127.0.0.1:1 ./dist/buzz channels list
# ...and that case specifically must have got PAST argument
# validation into the network, or it proves nothing about dispatch.
# Capture first, parse second. Piping the CLI straight into jq under
# `set -o pipefail` makes the pipeline inherit the CLI's exit 2 --
# which is the outcome being MEASURED -- so the assignment fails and
# `set -e` kills the step before the check runs. Never pipe the
# process whose failure is the signal.
relay_err=$(env BUZZ_PRIVATE_KEY=0000000000000000000000000000000000000000000000000000000000000001 \
BUZZ_RELAY_URL=http://127.0.0.1:1 ./dist/buzz channels list 2>&1 >/dev/null) || true
cat_seen=$(jq -r '.error' <<<"$relay_err")
echo " unreachable relay: error category = ${cat_seen}"
if [[ "$cat_seen" == "user_error" ]]; then
echo "::error::a valid key against a dead port still reported user_error, so the call never reached the relay"
exit 1
fi

# Identity must be readable from the file itself, or the install
# script cannot tell an operator what it just put on their box.
for b in buzz buzz-pair; do
id=$(readelf -n "dist/${b}" | awk '/Build ID/ {print $3}')
[[ -n "$id" ]] || { echo "::error::${b} carries no BuildID"; exit 1; }
done

- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: buzz-cli-linux-x86_64
path: dist/
if-no-files-found: error

# Only a deliberate dispatch that names a tag cuts a release. A branch
# push builds and uploads the run artifact and stops there.
- name: Publish release
if: github.event_name == 'workflow_dispatch' && inputs.release_tag != ''
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ inputs.release_tag }}
run: |
set -euo pipefail
gh release create "$TAG" \
--repo "$GITHUB_REPOSITORY" \
--target "$GITHUB_SHA" \
--title "buzz-cli $TAG" \
--notes "$(printf 'Linux x86-64 \`buzz\` and \`buzz-pair\`, built from %s by %s/%s/actions/runs/%s.\n\nInstall with scripts/install-buzz-cli.sh. Verify against SHA256SUMS before installing.\n\n```\n%s\n```\n' \
"$GITHUB_SHA" "$GITHUB_SERVER_URL" "$GITHUB_REPOSITORY" "$GITHUB_RUN_ID" "$(cat dist/PROVENANCE.txt)")" \
dist/buzz dist/buzz-pair dist/SHA256SUMS dist/PROVENANCE.txt
38 changes: 24 additions & 14 deletions .github/workflows/docker.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,24 @@ name: Docker image
# the relay image version tracks crates/buzz-relay/Cargo.toml, never desktop.
#
# Triggers:
# - push to main → :main + :sha-<7>
# + :debug-main + :debug-sha-<7>
# - push to main → build both images, publish nothing
# (was :main/:sha-<7> — see PUBLISH GATE below)
# - push tags relay-v*.*.* → :{version} + :{major}.{minor} + :{major}
# + matching :debug-* tags
# (+ :latest/:debug-latest for stable releases)
# - pull_request → build only (no push), cache stays warm
# - workflow_dispatch → manual relay-tag rescue at the tag itself
#
# PUBLISH GATE (5dive fork): every write to the registry — the image push, the
# buildcache export, the merged manifest and its attestation — fires only on a
# `relay-v*` tag push or a rescue `workflow_dispatch`, never on a `main` push or
# a PR. Same shape as helm-chart.yml's publish job. `main` pushes and PRs still
# build both images on both arches, so the compile signal is intact; they just
# cannot write to IMAGE_NAME's namespace, which on this fork defaults to
# upstream `ghcr.io/block/*` and is not ours to write to. This makes the
# namespace boundary a control in the workflow rather than an accident of which
# credentials happen to be missing.
#
# Why workflow_dispatch carries a version input:
# Normal releases arrive through the push:tags trigger above. The input is
# retained only for an operator to rerun publication manually at an immutable
Expand Down Expand Up @@ -175,11 +185,11 @@ jobs:
# Push by digest, not by tag — the merge job assembles the tags
# into one multi-arch manifest. This is what makes the native-arm
# matrix possible.
outputs: type=image,name=${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=${{ github.event_name != 'pull_request' }}
outputs: type=image,name=${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=${{ github.ref_type == 'tag' || github.event_name == 'workflow_dispatch' }}
cache-from: |
type=registry,ref=${{ env.IMAGE_NAME }}-buildcache:${{ matrix.arch }}
cache-to: |
${{ (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) && format('type=registry,ref={0}-buildcache:{1},mode=max,compression=zstd', env.IMAGE_NAME, matrix.arch) || '' }}
${{ (github.ref_type == 'tag' || github.event_name == 'workflow_dispatch') && format('type=registry,ref={0}-buildcache:{1},mode=max,compression=zstd', env.IMAGE_NAME, matrix.arch) || '' }}

- name: Build and push debug image by digest
id: build-debug
Expand All @@ -190,12 +200,12 @@ jobs:
target: runtime-debug
platforms: ${{ matrix.platform }}
labels: ${{ steps.meta.outputs.labels }}
outputs: type=image,name=${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=${{ github.event_name != 'pull_request' }}
outputs: type=image,name=${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=${{ github.ref_type == 'tag' || github.event_name == 'workflow_dispatch' }}
cache-from: |
type=registry,ref=${{ env.IMAGE_NAME }}-buildcache:${{ matrix.arch }}

- name: Export release and debug digests
if: github.event_name != 'pull_request'
if: github.ref_type == 'tag' || github.event_name == 'workflow_dispatch'
env:
RELEASE_DIGEST: ${{ steps.build-release.outputs.digest }}
DEBUG_DIGEST: ${{ steps.build-debug.outputs.digest }}
Expand All @@ -205,7 +215,7 @@ jobs:
touch "/tmp/digests-debug/${DEBUG_DIGEST#sha256:}"

- name: Upload release digest
if: github.event_name != 'pull_request'
if: github.ref_type == 'tag' || github.event_name == 'workflow_dispatch'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: digests-release-${{ matrix.arch }}
Expand All @@ -214,7 +224,7 @@ jobs:
retention-days: 1

- name: Upload debug digest
if: github.event_name != 'pull_request'
if: github.ref_type == 'tag' || github.event_name == 'workflow_dispatch'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: digests-debug-${{ matrix.arch }}
Expand All @@ -224,7 +234,7 @@ jobs:

merge:
name: Merge ${{ matrix.variant }} multi-arch manifest
if: github.event_name != 'pull_request'
if: github.ref_type == 'tag' || github.event_name == 'workflow_dispatch'
runs-on: ubuntu-24.04
needs: build
timeout-minutes: 15
Expand Down Expand Up @@ -400,16 +410,16 @@ jobs:
file: ./Dockerfile.push-gateway
platforms: ${{ matrix.platform }}
labels: ${{ steps.meta.outputs.labels }}
outputs: type=image,name=ghcr.io/block/buzz-push-gateway,push-by-digest=true,name-canonical=true,push=${{ github.event_name != 'pull_request' }}
outputs: type=image,name=ghcr.io/block/buzz-push-gateway,push-by-digest=true,name-canonical=true,push=${{ github.ref_type == 'tag' || github.event_name == 'workflow_dispatch' }}
cache-from: type=registry,ref=ghcr.io/block/buzz-push-gateway-buildcache:${{ matrix.arch }}
cache-to: ${{ (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) && format('type=registry,ref=ghcr.io/block/buzz-push-gateway-buildcache:{0},mode=max,compression=zstd', matrix.arch) || '' }}
cache-to: ${{ (github.ref_type == 'tag' || github.event_name == 'workflow_dispatch') && format('type=registry,ref=ghcr.io/block/buzz-push-gateway-buildcache:{0},mode=max,compression=zstd', matrix.arch) || '' }}
- name: Export digest
if: github.event_name != 'pull_request'
if: github.ref_type == 'tag' || github.event_name == 'workflow_dispatch'
env:
DIGEST: ${{ steps.build.outputs.digest }}
run: mkdir -p /tmp/gateway-digests && touch "/tmp/gateway-digests/${DIGEST#sha256:}"
- name: Upload digest
if: github.event_name != 'pull_request'
if: github.ref_type == 'tag' || github.event_name == 'workflow_dispatch'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: gateway-digests-${{ matrix.arch }}
Expand All @@ -419,7 +429,7 @@ jobs:

push-gateway-merge:
name: Publish public push gateway image
if: github.event_name != 'pull_request'
if: github.ref_type == 'tag' || github.event_name == 'workflow_dispatch'
runs-on: ubuntu-24.04
needs: push-gateway-build
timeout-minutes: 15
Expand Down
Loading
Loading