banger/scripts/publish-banger-release.sh
Thales Maciel 3c29af55a2
Add curl|bash installer + wire upload into publish script
scripts/install.sh is the one-command installer end users run as

  curl -fsSL https://releases.thaloco.com/banger/install.sh | bash

Design choices:

* Runs as the invoking user. All network work + signature verification
  happens unprivileged; sudo is only re-execed for the actual install
  step that writes to /usr/local and creates systemd units.
* Right before the sudo prompt, the script prints a plain-language
  summary of exactly what's about to happen — the file paths it will
  create and a one-line "why sudo" — so the user authorises a known
  scope rather than the whole pipeline. Detail link in the docs.
* Uses openssl (universally available) for signature verification, not
  cosign. cosign is needed only by the *signer*, never the verifier.
* No jq dependency. The latest_stable field is extracted from the
  manifest with grep+sed, since the manifest shape is well-defined and
  we control it.
* /dev/tty fallback for the confirmation prompt so it works through
  the curl|bash pipe.
* --yes for non-interactive CI use, --user for installing into
  ~/.local/bin without touching system paths, --version vX.Y.Z to pin.

publish-banger-release.sh now uploads install.sh to the bucket root
on every publish, so the curl URL is stable but the script logic
matches the latest verified release. It also runs a key-drift check:
if scripts/install.sh's embedded cosign public key differs from the
one in internal/updater/verify_signature.go, publishing aborts. The
two copies must stay in sync or one of them ends up rejecting every
release.

README's Quick start now leads with the installer one-liner and
documents the audit-first variant alongside it; building from source
moves below.

Smoke-tested end to end against the live bucket with --user mode:
manifest fetch → tarball download → cosign signature verify → hash
verify → extract → install. The installed binary reports v0.1.0 at
commit 6fdebd9, matching the published artifact.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 14:06:34 -03:00

233 lines
9.1 KiB
Bash
Executable file

#!/usr/bin/env bash
# publish-banger-release.sh
#
# Cut and publish a banger release tarball + SHA256SUMS + cosign
# signature to the R2 bucket consumed by `banger update`.
#
# Usage:
# scripts/publish-banger-release.sh v0.1.0
#
# Environment overrides:
# COSIGN_KEY path to the cosign private key (default: cosign.key)
# RCLONE_REMOTE rclone remote name (default: releases)
# RCLONE_BUCKET R2 bucket name (rclone target) (default: releases)
# BUCKET_PATH object-key prefix in the bucket (default: banger)
# BASE_URL public URL prefix for objects (default: https://releases.thaloco.com)
# SKIP_UPLOAD set to 1 to stage everything locally without rclone upload
#
# rclone path layout:
# ${RCLONE_REMOTE}:${RCLONE_BUCKET}/${BUCKET_PATH}/...
# i.e. defaults resolve to releases:releases/banger/v0.1.0/<file>.
# Public URLs in the manifest are ${BASE_URL}/${BUCKET_PATH}/<version>/<file>
# (BASE_URL is the bucket's public custom domain, so the bucket name
# itself is implicit there).
#
# Prerequisites:
# * cosign in PATH (https://github.com/sigstore/cosign)
# * rclone in PATH, configured with a remote named ${RCLONE_REMOTE}
# that targets the R2 account hosting ${RCLONE_BUCKET}, which is
# publicly served at ${BASE_URL}.
# * A cosign keypair already generated. The public key MUST already
# be embedded in internal/updater/verify_signature.go's
# BangerReleasePublicKey constant — running this script with a
# placeholder key would publish a release no installed banger can
# verify.
#
# Output (under build/release/<version>/):
# banger-<version>-linux-amd64.tar.gz
# SHA256SUMS
# SHA256SUMS.sig
# manifest.json (the freshly-mutated copy uploaded to the bucket)
set -euo pipefail
log() { printf '[publish-banger-release] %s\n' "$*" >&2; }
die() { log "$*"; exit 1; }
if [[ $# -lt 1 ]]; then
die "usage: $0 <version> (e.g. $0 v0.1.0)"
fi
VERSION="$1"
case "$VERSION" in
v*.*.*) ;;
*) die "version must look like vX.Y.Z, got $VERSION" ;;
esac
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
COSIGN_KEY="${COSIGN_KEY:-cosign.key}"
RCLONE_REMOTE="${RCLONE_REMOTE:-releases}"
RCLONE_BUCKET="${RCLONE_BUCKET:-releases}"
BUCKET_PATH="${BUCKET_PATH:-banger}"
BASE_URL="${BASE_URL:-https://releases.thaloco.com}"
SKIP_UPLOAD="${SKIP_UPLOAD:-0}"
RCLONE_DEST_BASE="$RCLONE_REMOTE:$RCLONE_BUCKET/$BUCKET_PATH"
command -v cosign >/dev/null || die "cosign not in PATH"
command -v rclone >/dev/null || die "rclone not in PATH"
command -v sha256sum >/dev/null || die "sha256sum not in PATH"
command -v jq >/dev/null || die "jq not in PATH"
[[ -f "$COSIGN_KEY" ]] || die "cosign key not found at $COSIGN_KEY (override with COSIGN_KEY=...)"
cd "$REPO_ROOT"
OUT_DIR="$REPO_ROOT/build/release/$VERSION"
TARBALL_NAME="banger-$VERSION-linux-amd64.tar.gz"
TARBALL_PATH="$OUT_DIR/$TARBALL_NAME"
log "preparing $OUT_DIR"
rm -rf "$OUT_DIR"
mkdir -p "$OUT_DIR"
log "building binaries with version=$VERSION"
COMMIT="$(git rev-parse HEAD)"
BUILT_AT="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
LDFLAGS="-X banger/internal/buildinfo.Version=$VERSION \
-X banger/internal/buildinfo.Commit=$COMMIT \
-X banger/internal/buildinfo.BuiltAt=$BUILT_AT"
BUILD_STAGE="$OUT_DIR/stage"
mkdir -p "$BUILD_STAGE"
go build -ldflags "$LDFLAGS" -o "$BUILD_STAGE/banger" ./cmd/banger
go build -ldflags "$LDFLAGS" -o "$BUILD_STAGE/bangerd" ./cmd/bangerd
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
go build -ldflags "$LDFLAGS" -o "$BUILD_STAGE/banger-vsock-agent" \
./cmd/banger-vsock-agent
log "tarring → $TARBALL_PATH"
# -C into the stage dir so the tarball's root entries are bare
# basenames (banger, bangerd, banger-vsock-agent) — the updater's
# StageTarball validator rejects anything else.
tar -czf "$TARBALL_PATH" -C "$BUILD_STAGE" \
banger bangerd banger-vsock-agent
log "computing SHA256SUMS"
(
cd "$OUT_DIR"
sha256sum "$TARBALL_NAME" > SHA256SUMS
cat SHA256SUMS
) >&2
log "cosign sign-blob → SHA256SUMS.sig"
# Flag rationale (cosign v3.x):
# --use-signing-config=false bypasses the new signing-config flow that
# otherwise insists on bundle output + Rekor.
# --tlog-upload=false skip the public transparency log; banger's
# trust model is "embedded public key", not
# "Rekor lookup", so the log adds nothing.
# --new-bundle-format=false emit a bare base64 ASN.1 DER signature,
# which is what internal/updater consumes
# via crypto/ecdsa.VerifyASN1.
# These flags also work on cosign v2.x, so the script is forward- and
# backward-compatible across the v2→v3 boundary.
# If COSIGN_PASSWORD is set in the environment, cosign uses it.
# Otherwise cosign prompts on the terminal — which is what we want
# for a password-protected offline key. Don't pre-set it to empty:
# that suppresses the prompt and makes cosign try to decrypt with
# the empty password, failing with "decryption failed".
cosign sign-blob --yes \
--key "$COSIGN_KEY" \
--use-signing-config=false \
--tlog-upload=false \
--new-bundle-format=false \
--output-signature "$OUT_DIR/SHA256SUMS.sig" \
"$OUT_DIR/SHA256SUMS"
log "verifying signature against the embedded public key"
EMBEDDED_PUB="$OUT_DIR/embedded-pubkey.pem"
# verify_signature.go embeds the PEM inside a Go raw-string literal, so the
# BEGIN line is prefixed with `var ... = ` + backtick and the END line has a
# trailing backtick. Strip those so the result is a clean PEM.
sed -n '/-----BEGIN PUBLIC KEY-----/,/-----END PUBLIC KEY-----/p' \
"$REPO_ROOT/internal/updater/verify_signature.go" \
| sed -E 's/.*(-----BEGIN PUBLIC KEY-----)/\1/; s/(-----END PUBLIC KEY-----).*/\1/' \
> "$EMBEDDED_PUB"
if grep -q PLACEHOLDER "$EMBEDDED_PUB"; then
die "BangerReleasePublicKey is the placeholder in verify_signature.go; replace it with cosign.pub before publishing"
fi
cosign verify-blob \
--key "$EMBEDDED_PUB" \
--insecure-ignore-tlog \
--signature "$OUT_DIR/SHA256SUMS.sig" \
"$OUT_DIR/SHA256SUMS"
# install.sh embeds its own copy of the public key for end-user
# verification (curl|bash trust path). Make sure the two copies didn't
# drift; a release with mismatched keys would either reject all
# `banger update` calls or all `install.sh | bash` runs.
log "checking install.sh embedded key matches verify_signature.go"
INSTALL_PUB="$OUT_DIR/install-script-pubkey.pem"
sed -n "/-----BEGIN PUBLIC KEY-----/,/-----END PUBLIC KEY-----/p" \
"$REPO_ROOT/scripts/install.sh" \
| sed -E "s/.*(-----BEGIN PUBLIC KEY-----)/\\1/; s/(-----END PUBLIC KEY-----).*/\\1/" \
> "$INSTALL_PUB"
diff -q "$EMBEDDED_PUB" "$INSTALL_PUB" >/dev/null \
|| die "scripts/install.sh embedded key differs from internal/updater/verify_signature.go; sync them before publishing"
# Build the manifest. Pull the existing manifest from the bucket so
# we don't lose previous release entries, append this one, bump
# latest_stable, write back.
log "fetching existing manifest"
PREV_MANIFEST="$OUT_DIR/manifest.previous.json"
if curl -fsSL "$BASE_URL/$BUCKET_PATH/manifest.json" -o "$PREV_MANIFEST" 2>/dev/null; then
log " found previous manifest"
else
log " no previous manifest (first release); seeding"
printf '{"schema_version":1,"latest_stable":"","releases":[]}' > "$PREV_MANIFEST"
fi
NEW_MANIFEST="$OUT_DIR/manifest.json"
RELEASED_AT="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
jq --arg version "$VERSION" \
--arg tarball_url "$BASE_URL/$BUCKET_PATH/$VERSION/$TARBALL_NAME" \
--arg sums_url "$BASE_URL/$BUCKET_PATH/$VERSION/SHA256SUMS" \
--arg sig_url "$BASE_URL/$BUCKET_PATH/$VERSION/SHA256SUMS.sig" \
--arg released_at "$RELEASED_AT" \
'
.schema_version = 1
| .latest_stable = $version
| .releases = (
(.releases // [])
| map(select(.version != $version))
| . + [{
"version": $version,
"tarball_url": $tarball_url,
"sha256sums_url": $sums_url,
"sha256sums_sig_url": $sig_url,
"released_at": $released_at
}]
)
' "$PREV_MANIFEST" > "$NEW_MANIFEST"
log "manifest:"
jq '.' "$NEW_MANIFEST" >&2
if [[ "$SKIP_UPLOAD" == "1" ]]; then
log "SKIP_UPLOAD=1, not uploading. Artifacts staged under $OUT_DIR"
exit 0
fi
log "uploading to $RCLONE_DEST_BASE/$VERSION/"
rclone copy "$TARBALL_PATH" "$RCLONE_DEST_BASE/$VERSION/"
rclone copy "$OUT_DIR/SHA256SUMS" "$RCLONE_DEST_BASE/$VERSION/"
rclone copy "$OUT_DIR/SHA256SUMS.sig" "$RCLONE_DEST_BASE/$VERSION/"
log "uploading manifest"
rclone copy "$NEW_MANIFEST" "$RCLONE_DEST_BASE/"
# install.sh lives at the bucket root (unversioned) so the
# `curl ... install.sh | bash` URL stays stable across releases. The
# script reads manifest.json to find the current latest_stable, so as
# long as install.sh's logic doesn't break, it keeps working for older
# releases too.
log "uploading install.sh"
rclone copy "$REPO_ROOT/scripts/install.sh" "$RCLONE_DEST_BASE/"
log "done. verify with:"
log " curl -fsSL $BASE_URL/$BUCKET_PATH/manifest.json | jq ."
log " curl -fsSL $BASE_URL/$BUCKET_PATH/install.sh | head -20"
log " banger update --check"