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>
233 lines
9.1 KiB
Bash
Executable file
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"
|