feat: import Chinese-localized Buzz source snapshot
Docker image / Build (linux/amd64) (push) Has been cancelled
Docker image / Build (linux/arm64) (push) Has been cancelled
Docker image / Merge release multi-arch manifest (push) Has been cancelled
Docker image / Merge debug multi-arch manifest (push) Has been cancelled
Docker image / Build public push gateway (linux/amd64) (push) Has been cancelled
Docker image / Build public push gateway (linux/arm64) (push) Has been cancelled
Docker image / Publish public push gateway image (push) Has been cancelled
Sprig image / Build (linux/amd64) (push) Has been cancelled
Sprig image / Build (linux/arm64) (push) Has been cancelled
Sprig image / Merge multi-arch manifest (push) Has been cancelled
Harbor Buzz Orchestra / Python tests and lint (push) Has been cancelled
CI / Detect Changed Paths (push) Has been cancelled
CI / Rust Lint (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Desktop Core (push) Has been cancelled
CI / Desktop Smoke E2E (1) (push) Has been cancelled
CI / Desktop Smoke E2E (2) (push) Has been cancelled
CI / Desktop Smoke E2E (3) (push) Has been cancelled
CI / Desktop Smoke E2E (4) (push) Has been cancelled
CI / Desktop (push) Has been cancelled
CI / Desktop E2E Relay (push) Has been cancelled
CI / Desktop E2E Integration (1/2) (push) Has been cancelled
CI / Desktop E2E Integration (2/2) (push) Has been cancelled
CI / Desktop E2E Integration (push) Has been cancelled
CI / Backend Integration (relay e2e) (push) Has been cancelled
CI / Relay E2E (push) Has been cancelled
CI / Web (push) Has been cancelled
CI / Mobile (push) Has been cancelled
CI / Security (push) Has been cancelled
CI / Dead Token Reference Guard (push) Has been cancelled
CI / Server Cross-Compile (aarch64-unknown-linux-musl) (push) Has been cancelled
CI / Server Cross-Compile (x86_64-unknown-linux-musl) (push) Has been cancelled
CI / Windows Rust (x86_64-pc-windows-msvc) (push) Has been cancelled
CI / Desktop Build (macOS) (push) Has been cancelled
helm chart / lint + unittest + render matrix (push) Has been cancelled
helm chart / install on kind (gated) (push) Has been cancelled
helm chart / publish chart to GHCR (push) Has been cancelled
Mesh Lifecycle / Relay-Driven Mesh Lifecycle Smoke (push) Has been cancelled
Sprig / Build (aarch64-unknown-linux-musl) (push) Has been cancelled
Sprig / Build (x86_64-unknown-linux-musl) (push) Has been cancelled
Sprig / Publish rolling release (push) Has been cancelled
Sprig / Publish tagged release (push) Has been cancelled
Docker image / Build (linux/amd64) (push) Has been cancelled
Docker image / Build (linux/arm64) (push) Has been cancelled
Docker image / Merge release multi-arch manifest (push) Has been cancelled
Docker image / Merge debug multi-arch manifest (push) Has been cancelled
Docker image / Build public push gateway (linux/amd64) (push) Has been cancelled
Docker image / Build public push gateway (linux/arm64) (push) Has been cancelled
Docker image / Publish public push gateway image (push) Has been cancelled
Sprig image / Build (linux/amd64) (push) Has been cancelled
Sprig image / Build (linux/arm64) (push) Has been cancelled
Sprig image / Merge multi-arch manifest (push) Has been cancelled
Harbor Buzz Orchestra / Python tests and lint (push) Has been cancelled
CI / Detect Changed Paths (push) Has been cancelled
CI / Rust Lint (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Desktop Core (push) Has been cancelled
CI / Desktop Smoke E2E (1) (push) Has been cancelled
CI / Desktop Smoke E2E (2) (push) Has been cancelled
CI / Desktop Smoke E2E (3) (push) Has been cancelled
CI / Desktop Smoke E2E (4) (push) Has been cancelled
CI / Desktop (push) Has been cancelled
CI / Desktop E2E Relay (push) Has been cancelled
CI / Desktop E2E Integration (1/2) (push) Has been cancelled
CI / Desktop E2E Integration (2/2) (push) Has been cancelled
CI / Desktop E2E Integration (push) Has been cancelled
CI / Backend Integration (relay e2e) (push) Has been cancelled
CI / Relay E2E (push) Has been cancelled
CI / Web (push) Has been cancelled
CI / Mobile (push) Has been cancelled
CI / Security (push) Has been cancelled
CI / Dead Token Reference Guard (push) Has been cancelled
CI / Server Cross-Compile (aarch64-unknown-linux-musl) (push) Has been cancelled
CI / Server Cross-Compile (x86_64-unknown-linux-musl) (push) Has been cancelled
CI / Windows Rust (x86_64-pc-windows-msvc) (push) Has been cancelled
CI / Desktop Build (macOS) (push) Has been cancelled
helm chart / lint + unittest + render matrix (push) Has been cancelled
helm chart / install on kind (gated) (push) Has been cancelled
helm chart / publish chart to GHCR (push) Has been cancelled
Mesh Lifecycle / Relay-Driven Mesh Lifecycle Smoke (push) Has been cancelled
Sprig / Build (aarch64-unknown-linux-musl) (push) Has been cancelled
Sprig / Build (x86_64-unknown-linux-musl) (push) Has been cancelled
Sprig / Publish rolling release (push) Has been cancelled
Sprig / Publish tagged release (push) Has been cancelled
Signed-off-by: cls_宁波本机 <908705107@qq.com>
This commit is contained in:
@@ -0,0 +1,26 @@
|
||||
#!/usr/bin/env bash
|
||||
# Sourced by the goose and goose-bg just recipes to build shared agent env_args.
|
||||
# Usage: source scripts/_goose-env.sh <relay> <key> <agents> <heartbeat> <prompt>
|
||||
# Sets: env_args (bash array), ready for: exec env "${env_args[@]}" <binary>
|
||||
set -euo pipefail
|
||||
|
||||
_relay="$1"
|
||||
_key="$2"
|
||||
_agents="$3"
|
||||
_heartbeat="$4"
|
||||
_prompt="${5:-}"
|
||||
|
||||
cargo build --release -p buzz-acp -p buzz-cli
|
||||
|
||||
env_args=(
|
||||
BUZZ_RELAY_URL="$_relay"
|
||||
BUZZ_PRIVATE_KEY="$_key"
|
||||
BUZZ_ACP_AGENT_COMMAND=goose
|
||||
BUZZ_ACP_AGENT_ARGS=acp
|
||||
BUZZ_ACP_AGENTS="$_agents"
|
||||
GOOSE_MODE=auto
|
||||
)
|
||||
[[ -n "$_prompt" ]] && env_args+=(BUZZ_ACP_SYSTEM_PROMPT="$_prompt")
|
||||
if [[ "$_heartbeat" != "0" ]]; then
|
||||
env_args+=(BUZZ_ACP_HEARTBEAT_INTERVAL="$_heartbeat")
|
||||
fi
|
||||
@@ -0,0 +1,178 @@
|
||||
-- Attach partition child tables after pgschema apply.
|
||||
--
|
||||
-- pgschema currently emits existing partition children as standalone CREATE TABLE
|
||||
-- statements when applying schema/schema.sql in CI. The tables exist, but they
|
||||
-- are not attached to their partitioned parents, so inserts into events or
|
||||
-- delivery_log fail with "no partition of relation ... found for row". Keep this
|
||||
-- idempotent: raw psql/schema.sql already attaches these partitions, while
|
||||
-- pgschema-created schemas need this repair step.
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_inherits
|
||||
WHERE inhparent = 'events'::regclass
|
||||
AND inhrelid = 'events_p_past'::regclass
|
||||
) THEN
|
||||
-- pgschema may copy parent triggers onto standalone children. Drop
|
||||
-- those copies before ATTACH; PostgreSQL recreates inherited parent
|
||||
-- triggers while attaching and rejects same-named child triggers
|
||||
-- (both the push-match trigger and the replica-fence floor guard).
|
||||
DROP TRIGGER IF EXISTS events_enqueue_push_match ON events_p_past;
|
||||
DROP TRIGGER IF EXISTS events_refresh_channel_ttl ON events_p_past;
|
||||
DROP TRIGGER IF EXISTS events_created_at_floor ON events_p_past;
|
||||
ALTER TABLE events ATTACH PARTITION events_p_past
|
||||
FOR VALUES FROM (MINVALUE) TO ('2026-01-01');
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_inherits
|
||||
WHERE inhparent = 'events'::regclass
|
||||
AND inhrelid = 'events_p2026_01'::regclass
|
||||
) THEN
|
||||
DROP TRIGGER IF EXISTS events_enqueue_push_match ON events_p2026_01;
|
||||
DROP TRIGGER IF EXISTS events_refresh_channel_ttl ON events_p2026_01;
|
||||
DROP TRIGGER IF EXISTS events_created_at_floor ON events_p2026_01;
|
||||
ALTER TABLE events ATTACH PARTITION events_p2026_01
|
||||
FOR VALUES FROM ('2026-01-01') TO ('2026-02-01');
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_inherits
|
||||
WHERE inhparent = 'events'::regclass
|
||||
AND inhrelid = 'events_p2026_02'::regclass
|
||||
) THEN
|
||||
DROP TRIGGER IF EXISTS events_enqueue_push_match ON events_p2026_02;
|
||||
DROP TRIGGER IF EXISTS events_refresh_channel_ttl ON events_p2026_02;
|
||||
DROP TRIGGER IF EXISTS events_created_at_floor ON events_p2026_02;
|
||||
ALTER TABLE events ATTACH PARTITION events_p2026_02
|
||||
FOR VALUES FROM ('2026-02-01') TO ('2026-03-01');
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_inherits
|
||||
WHERE inhparent = 'events'::regclass
|
||||
AND inhrelid = 'events_p2026_03'::regclass
|
||||
) THEN
|
||||
DROP TRIGGER IF EXISTS events_enqueue_push_match ON events_p2026_03;
|
||||
DROP TRIGGER IF EXISTS events_refresh_channel_ttl ON events_p2026_03;
|
||||
DROP TRIGGER IF EXISTS events_created_at_floor ON events_p2026_03;
|
||||
ALTER TABLE events ATTACH PARTITION events_p2026_03
|
||||
FOR VALUES FROM ('2026-03-01') TO ('2026-04-01');
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_inherits
|
||||
WHERE inhparent = 'events'::regclass
|
||||
AND inhrelid = 'events_p2026_04'::regclass
|
||||
) THEN
|
||||
DROP TRIGGER IF EXISTS events_enqueue_push_match ON events_p2026_04;
|
||||
DROP TRIGGER IF EXISTS events_refresh_channel_ttl ON events_p2026_04;
|
||||
DROP TRIGGER IF EXISTS events_created_at_floor ON events_p2026_04;
|
||||
ALTER TABLE events ATTACH PARTITION events_p2026_04
|
||||
FOR VALUES FROM ('2026-04-01') TO ('2026-05-01');
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_inherits
|
||||
WHERE inhparent = 'events'::regclass
|
||||
AND inhrelid = 'events_p2026_05'::regclass
|
||||
) THEN
|
||||
DROP TRIGGER IF EXISTS events_enqueue_push_match ON events_p2026_05;
|
||||
DROP TRIGGER IF EXISTS events_refresh_channel_ttl ON events_p2026_05;
|
||||
DROP TRIGGER IF EXISTS events_created_at_floor ON events_p2026_05;
|
||||
ALTER TABLE events ATTACH PARTITION events_p2026_05
|
||||
FOR VALUES FROM ('2026-05-01') TO ('2026-06-01');
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_inherits
|
||||
WHERE inhparent = 'events'::regclass
|
||||
AND inhrelid = 'events_p2026_06'::regclass
|
||||
) THEN
|
||||
DROP TRIGGER IF EXISTS events_enqueue_push_match ON events_p2026_06;
|
||||
DROP TRIGGER IF EXISTS events_refresh_channel_ttl ON events_p2026_06;
|
||||
DROP TRIGGER IF EXISTS events_created_at_floor ON events_p2026_06;
|
||||
ALTER TABLE events ATTACH PARTITION events_p2026_06
|
||||
FOR VALUES FROM ('2026-06-01') TO ('2026-07-01');
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_inherits
|
||||
WHERE inhparent = 'events'::regclass
|
||||
AND inhrelid = 'events_p_future'::regclass
|
||||
) THEN
|
||||
DROP TRIGGER IF EXISTS events_enqueue_push_match ON events_p_future;
|
||||
DROP TRIGGER IF EXISTS events_refresh_channel_ttl ON events_p_future;
|
||||
DROP TRIGGER IF EXISTS events_created_at_floor ON events_p_future;
|
||||
ALTER TABLE events ATTACH PARTITION events_p_future
|
||||
FOR VALUES FROM ('2026-07-01') TO (MAXVALUE);
|
||||
END IF;
|
||||
|
||||
-- When pgschema creates partition children as standalone tables, it also
|
||||
-- preserves the parent's identity column on delivery_log children. PostgreSQL
|
||||
-- rejects attaching a child table that has its own identity column, so each
|
||||
-- delivery_log attach path drops that standalone identity first. Raw
|
||||
-- schema-created partitions are already attached, so these branches do not
|
||||
-- run against inherited partition columns.
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_inherits
|
||||
WHERE inhparent = 'delivery_log'::regclass
|
||||
AND inhrelid = 'delivery_log_p_past'::regclass
|
||||
) THEN
|
||||
ALTER TABLE delivery_log_p_past ALTER COLUMN id DROP IDENTITY IF EXISTS;
|
||||
ALTER TABLE delivery_log ATTACH PARTITION delivery_log_p_past
|
||||
FOR VALUES FROM (MINVALUE) TO ('2026-03-01');
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_inherits
|
||||
WHERE inhparent = 'delivery_log'::regclass
|
||||
AND inhrelid = 'delivery_log_p2026_03'::regclass
|
||||
) THEN
|
||||
ALTER TABLE delivery_log_p2026_03 ALTER COLUMN id DROP IDENTITY IF EXISTS;
|
||||
ALTER TABLE delivery_log ATTACH PARTITION delivery_log_p2026_03
|
||||
FOR VALUES FROM ('2026-03-01') TO ('2026-04-01');
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_inherits
|
||||
WHERE inhparent = 'delivery_log'::regclass
|
||||
AND inhrelid = 'delivery_log_p2026_04'::regclass
|
||||
) THEN
|
||||
ALTER TABLE delivery_log_p2026_04 ALTER COLUMN id DROP IDENTITY IF EXISTS;
|
||||
ALTER TABLE delivery_log ATTACH PARTITION delivery_log_p2026_04
|
||||
FOR VALUES FROM ('2026-04-01') TO ('2026-05-01');
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_inherits
|
||||
WHERE inhparent = 'delivery_log'::regclass
|
||||
AND inhrelid = 'delivery_log_p2026_05'::regclass
|
||||
) THEN
|
||||
ALTER TABLE delivery_log_p2026_05 ALTER COLUMN id DROP IDENTITY IF EXISTS;
|
||||
ALTER TABLE delivery_log ATTACH PARTITION delivery_log_p2026_05
|
||||
FOR VALUES FROM ('2026-05-01') TO ('2026-06-01');
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_inherits
|
||||
WHERE inhparent = 'delivery_log'::regclass
|
||||
AND inhrelid = 'delivery_log_p2026_06'::regclass
|
||||
) THEN
|
||||
ALTER TABLE delivery_log_p2026_06 ALTER COLUMN id DROP IDENTITY IF EXISTS;
|
||||
ALTER TABLE delivery_log ATTACH PARTITION delivery_log_p2026_06
|
||||
FOR VALUES FROM ('2026-06-01') TO ('2026-07-01');
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_inherits
|
||||
WHERE inhparent = 'delivery_log'::regclass
|
||||
AND inhrelid = 'delivery_log_p_future'::regclass
|
||||
) THEN
|
||||
ALTER TABLE delivery_log_p_future ALTER COLUMN id DROP IDENTITY IF EXISTS;
|
||||
ALTER TABLE delivery_log ATTACH PARTITION delivery_log_p_future
|
||||
FOR VALUES FROM ('2026-07-01') TO (MAXVALUE);
|
||||
END IF;
|
||||
END $$;
|
||||
@@ -0,0 +1,17 @@
|
||||
-- Backfill d_tag for existing NIP-33 range events (kind 30000–39999).
|
||||
-- Idempotent: only updates rows where d_tag is still NULL.
|
||||
-- Includes soft-deleted rows so the column is fully populated.
|
||||
-- Run once after adding the d_tag column to the events table.
|
||||
--
|
||||
-- Usage: psql $DATABASE_URL -f scripts/backfill-d-tag.sql
|
||||
|
||||
UPDATE events
|
||||
SET d_tag = COALESCE(
|
||||
(SELECT elem->>1
|
||||
FROM jsonb_array_elements(tags) AS elem
|
||||
WHERE elem->>0 = 'd'
|
||||
LIMIT 1),
|
||||
''
|
||||
)
|
||||
WHERE kind BETWEEN 30000 AND 39999
|
||||
AND d_tag IS NULL;
|
||||
@@ -0,0 +1,194 @@
|
||||
#!/usr/bin/env bash
|
||||
# Build Sprig — one deploy-anywhere multicall binary for the Buzz ACP
|
||||
# harness, agent, and developer MCP. The archive exposes these command names:
|
||||
#
|
||||
# sprig implementation binary
|
||||
# buzz-acp link to sprig (ACP harness)
|
||||
# buzz-agent link to sprig (ACP-compliant agent)
|
||||
# buzz-dev-mcp link to sprig (developer MCP server; also dispatches
|
||||
# rg/tree/buzz/git-credential-nostr/git-sign-nostr)
|
||||
#
|
||||
# Usage:
|
||||
# ./scripts/build-sprig.sh [version] [target]
|
||||
#
|
||||
# Environment overrides:
|
||||
# TARGET cross-compile target (defaults to host)
|
||||
# USE_CROSS=1 use `cross` instead of `cargo` for the build
|
||||
# BUILD_PROFILE Cargo profile to build/package (default: sprig).
|
||||
# Set BUILD_PROFILE=release to use Cargo's default release
|
||||
# profile. BUILD_PROFILE=dev/debug is rejected because Cargo
|
||||
# writes dev builds to target/debug, not target/dev.
|
||||
# SKIP_BUILD=1 skip the cargo/cross build (use a prebuilt sprig already
|
||||
# present in target/[<target>/]<profile>)
|
||||
# ARCHIVE_BASENAME override the archive basename (sans .tar.gz). Useful for
|
||||
# rolling releases where the asset filename should be stable
|
||||
# across builds (e.g. `sprig-<target>`). Defaults to
|
||||
# `sprig-<version>-<target>`.
|
||||
# DIST_DIR output directory (default: dist)
|
||||
#
|
||||
# Output:
|
||||
# ${DIST_DIR}/${ARCHIVE_BASENAME}.tar.gz
|
||||
# ${DIST_DIR}/${ARCHIVE_BASENAME}.tar.gz.sha256
|
||||
#
|
||||
# The tarball contains:
|
||||
# sprig
|
||||
# buzz-acp
|
||||
# buzz-agent
|
||||
# buzz-dev-mcp
|
||||
# README.md
|
||||
# sprig.json { version, git_sha, target, binaries: [{name, sha256, size}] }
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
VERSION="${1:-${VERSION:-0.0.0-dev}}"
|
||||
HOST_TARGET="$(rustc -vV | sed -n 's|host: ||p')"
|
||||
TARGET="${2:-${TARGET:-$HOST_TARGET}}"
|
||||
DIST_DIR="${DIST_DIR:-dist}"
|
||||
BUILD_PROFILE="${BUILD_PROFILE:-sprig}"
|
||||
case "$BUILD_PROFILE" in
|
||||
dev|debug)
|
||||
echo "error: BUILD_PROFILE=$BUILD_PROFILE is not supported by this script; Cargo writes dev builds to target/debug. Use a release-like profile such as 'sprig' or 'release'." >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
if GIT_SHA="$(git rev-parse HEAD 2>/dev/null)"; then
|
||||
:
|
||||
else
|
||||
GIT_SHA="unknown"
|
||||
fi
|
||||
|
||||
BUNDLE_BIN="sprig"
|
||||
COMMANDS=(buzz-acp buzz-agent buzz-dev-mcp)
|
||||
|
||||
echo "==> Building Sprig v${VERSION} for ${TARGET}"
|
||||
echo " git_sha=${GIT_SHA}"
|
||||
echo " binary=${BUNDLE_BIN}"
|
||||
echo " commands=${COMMANDS[*]}"
|
||||
echo " cargo_profile=${BUILD_PROFILE}"
|
||||
|
||||
if [[ "${USE_CROSS:-0}" == "1" ]] || [[ "$TARGET" != "$HOST_TARGET" ]]; then
|
||||
if ! command -v cross >/dev/null 2>&1; then
|
||||
echo "error: cross-compiling to $TARGET requires \`cross\` (install: cargo install cross --version 0.2.5)" >&2
|
||||
exit 1
|
||||
fi
|
||||
BUILDER=(cross build --profile "$BUILD_PROFILE" --target "$TARGET")
|
||||
BIN_DIR="target/${TARGET}/${BUILD_PROFILE}"
|
||||
else
|
||||
BUILDER=(cargo build --profile "$BUILD_PROFILE")
|
||||
BIN_DIR="target/${BUILD_PROFILE}"
|
||||
fi
|
||||
|
||||
if [[ "${SKIP_BUILD:-0}" == "1" ]]; then
|
||||
echo " (SKIP_BUILD=1 set — expecting prebuilt ${BUNDLE_BIN} in ${BIN_DIR}/)"
|
||||
else
|
||||
"${BUILDER[@]}" -p "$BUNDLE_BIN"
|
||||
fi
|
||||
|
||||
if [[ ! -f "${BIN_DIR}/${BUNDLE_BIN}" ]]; then
|
||||
echo "error: ${BIN_DIR}/${BUNDLE_BIN} not found after build" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p "${DIST_DIR}"
|
||||
STAGING="$(mktemp -d)"
|
||||
trap 'rm -rf "${STAGING}"' EXIT
|
||||
|
||||
sha256_of() {
|
||||
if command -v sha256sum >/dev/null 2>&1; then
|
||||
sha256sum "$1" | awk '{print $1}'
|
||||
else
|
||||
shasum -a 256 "$1" | awk '{print $1}'
|
||||
fi
|
||||
}
|
||||
|
||||
cp "${BIN_DIR}/${BUNDLE_BIN}" "${STAGING}/${BUNDLE_BIN}"
|
||||
chmod 0755 "${STAGING}/${BUNDLE_BIN}"
|
||||
if command -v strip >/dev/null 2>&1; then
|
||||
strip "${STAGING}/${BUNDLE_BIN}" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
MANIFEST_ENTRIES=()
|
||||
ALL_NAMES=("${BUNDLE_BIN}" "${COMMANDS[@]}")
|
||||
for bin in "${ALL_NAMES[@]}"; do
|
||||
if [[ "$bin" != "$BUNDLE_BIN" ]]; then
|
||||
ln -s "${BUNDLE_BIN}" "${STAGING}/${bin}"
|
||||
fi
|
||||
sha="$(sha256_of "${STAGING}/${bin}")"
|
||||
size="$(wc -c < "${STAGING}/${bin}" | tr -d ' ')"
|
||||
MANIFEST_ENTRIES+=("{\"name\":\"${bin}\",\"sha256\":\"${sha}\",\"size\":${size}}")
|
||||
done
|
||||
|
||||
ENTRIES_JSON="$(IFS=,; echo "${MANIFEST_ENTRIES[*]}")"
|
||||
cat > "${STAGING}/sprig.json" <<JSON
|
||||
{
|
||||
"name": "sprig",
|
||||
"version": "${VERSION}",
|
||||
"git_sha": "${GIT_SHA}",
|
||||
"target": "${TARGET}",
|
||||
"binaries": [${ENTRIES_JSON}]
|
||||
}
|
||||
JSON
|
||||
|
||||
cat > "${STAGING}/README.md" <<'README'
|
||||
# Sprig
|
||||
|
||||
Sprig is the all-in-one Buzz agent binary for deploy-anywhere environments.
|
||||
It exposes the ACP harness, ACP agent, and developer MCP command names as symlinks
|
||||
to one multicall binary so shared Rust runtime/TLS code is stored only once.
|
||||
|
||||
Commands:
|
||||
|
||||
- `sprig` — prints usage/version. Invoke a personality by one of the links below.
|
||||
- `buzz-acp` — ACP harness that bridges Buzz channel events to an
|
||||
ACP-compliant agent over stdio.
|
||||
- `buzz-agent` — ACP-compliant agent (spawns MCP servers, calls LLMs).
|
||||
- `buzz-dev-mcp` — Developer MCP server (shell, str_replace, todo) and
|
||||
multicall entrypoint for `rg`, `tree`, `buzz`, `git-credential-nostr`,
|
||||
`git-sign-nostr`.
|
||||
|
||||
See `sprig.json` for SHA-256s, sizes, target, and source git SHA.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
tar -xzf sprig-*.tar.gz -C /opt/sprig
|
||||
export PATH="/opt/sprig:$PATH"
|
||||
```
|
||||
|
||||
## Configure
|
||||
|
||||
```bash
|
||||
# Agent provider
|
||||
export BUZZ_AGENT_PROVIDER=anthropic # or openai
|
||||
export ANTHROPIC_API_KEY=sk-...
|
||||
export ANTHROPIC_MODEL=claude-sonnet-4-20250514
|
||||
|
||||
# Nostr identity (shared by buzz-acp, git auth, signing, and buzz CLI)
|
||||
export NOSTR_PRIVATE_KEY=nsec1...
|
||||
export BUZZ_PRIVATE_KEY="$NOSTR_PRIVATE_KEY"
|
||||
export BUZZ_RELAY_URL=https://your-relay.example.com
|
||||
```
|
||||
README
|
||||
|
||||
ARCHIVE_BASENAME="${ARCHIVE_BASENAME:-sprig-${VERSION}-${TARGET}}"
|
||||
ARCHIVE_NAME="${ARCHIVE_BASENAME}.tar.gz"
|
||||
ARCHIVE_PATH="${DIST_DIR}/${ARCHIVE_NAME}"
|
||||
|
||||
tar \
|
||||
--sort=name \
|
||||
--owner=0 --group=0 --numeric-owner \
|
||||
-czf "${ARCHIVE_PATH}" \
|
||||
-C "${STAGING}" \
|
||||
. 2>/dev/null || \
|
||||
tar -czf "${ARCHIVE_PATH}" -C "${STAGING}" .
|
||||
|
||||
sha256_of "${ARCHIVE_PATH}" > "${ARCHIVE_PATH}.sha256"
|
||||
echo "$(cat "${ARCHIVE_PATH}.sha256") ${ARCHIVE_NAME}" > "${ARCHIVE_PATH}.sha256"
|
||||
|
||||
echo ""
|
||||
echo "==> Built: ${ARCHIVE_PATH}"
|
||||
ls -lh "${ARCHIVE_PATH}" "${ARCHIVE_PATH}.sha256"
|
||||
echo ""
|
||||
echo "==> sprig.json:"
|
||||
sed 's/^/ /' "${STAGING}/sprig.json"
|
||||
@@ -0,0 +1,54 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SIDECARS=(buzz-acp buzz-agent buzz-dev-mcp git-credential-nostr buzz)
|
||||
HOST=$(rustc -vV | sed -n 's|host: ||p')
|
||||
TARGET=${1:-$HOST}
|
||||
if [[ "$TARGET" != *windows* ]]; then
|
||||
SIDECARS+=(buzz-backend-kubernetes)
|
||||
BUILD_HINT="cargo build --release -p buzz-acp -p buzz-agent -p buzz-backend-kubernetes -p buzz-dev-mcp -p git-credential-nostr -p buzz-cli"
|
||||
else
|
||||
BUILD_HINT="cargo build --release -p buzz-acp -p buzz-agent -p buzz-dev-mcp -p git-credential-nostr -p buzz-cli"
|
||||
fi
|
||||
BINARIES_DIR="desktop/src-tauri/binaries"
|
||||
|
||||
# When --target is passed explicitly to cargo (even if it matches the host),
|
||||
# binaries land in target/<triple>/release/. Without --target, they land in
|
||||
# target/release/. The script receives the target as $1 only when cargo was
|
||||
# invoked with --target, so use the qualified path whenever $1 is set.
|
||||
if [[ -n "${1:-}" ]]; then
|
||||
SRC_DIR="target/${TARGET}/release"
|
||||
else
|
||||
SRC_DIR="target/release"
|
||||
fi
|
||||
|
||||
# MSVC emits <name>.exe; Tauri's externalBin then expects binaries/<name>-<triple>.exe.
|
||||
if [[ "$TARGET" == *windows* ]]; then
|
||||
EXE=".exe"
|
||||
else
|
||||
EXE=""
|
||||
fi
|
||||
|
||||
missing=()
|
||||
for bin in "${SIDECARS[@]}"; do
|
||||
[[ -f "$SRC_DIR/${bin}${EXE}" ]] || missing+=("${bin}${EXE}")
|
||||
done
|
||||
if [[ ${#missing[@]} -gt 0 ]]; then
|
||||
echo "Error: missing release binaries in $SRC_DIR: ${missing[*]}" >&2
|
||||
echo "Run '$BUILD_HINT' first." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p "$BINARIES_DIR"
|
||||
for bin in "${SIDECARS[@]}"; do
|
||||
destination="$BINARIES_DIR/${bin}-${TARGET}${EXE}"
|
||||
cp "$SRC_DIR/${bin}${EXE}" "$destination"
|
||||
|
||||
# cp preserves the mode of an existing destination on macOS. Generated
|
||||
# sidecar placeholders may not be executable, so make the bundled Unix
|
||||
# binaries executable explicitly.
|
||||
if [[ -z "$EXE" ]]; then
|
||||
chmod 755 "$destination"
|
||||
fi
|
||||
done
|
||||
echo "Sidecars bundled for $TARGET"
|
||||
@@ -0,0 +1,34 @@
|
||||
#!/usr/bin/env bash
|
||||
# Pre-push guard: CI checks the PR merged with main, so local runs on a
|
||||
# skewed branch can pass while CI fails. Block the push only when
|
||||
# origin/main has changed files this branch also touches.
|
||||
set -euo pipefail
|
||||
|
||||
branch=$(git rev-parse --abbrev-ref HEAD)
|
||||
if [ "$branch" = "main" ] || [ "$branch" = "HEAD" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
git fetch --quiet origin main || true
|
||||
git rev-parse --verify --quiet origin/main >/dev/null || exit 0
|
||||
|
||||
base=$(git merge-base HEAD origin/main)
|
||||
if [ "$base" = "$(git rev-parse origin/main)" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
overlap=$(comm -12 \
|
||||
<(git diff --name-only "$base" origin/main -- | sort) \
|
||||
<(git diff --name-only "$base" HEAD -- | sort))
|
||||
|
||||
if [ -z "$overlap" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
{
|
||||
echo "Branch is behind origin/main, and main changed files this branch also touches:"
|
||||
echo "$overlap" | sed 's/^/ /'
|
||||
echo "Local checks ran on a tree CI will never test. Run 'git merge origin/main',"
|
||||
echo "resolve, re-run checks, then push."
|
||||
} >&2
|
||||
exit 1
|
||||
@@ -0,0 +1,174 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { promises as fs } from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
function git(args, cwd, options = {}) {
|
||||
return execFileSync("git", args, {
|
||||
cwd,
|
||||
encoding: "utf8",
|
||||
maxBuffer: 10 * 1024 * 1024,
|
||||
...options,
|
||||
});
|
||||
}
|
||||
|
||||
function toPosixPath(relativePath) {
|
||||
return relativePath.split(path.sep).join("/");
|
||||
}
|
||||
|
||||
export function countLines(content) {
|
||||
if (content.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
return content.split(/\r?\n/).length;
|
||||
}
|
||||
|
||||
export function allowedLineCount(baseLines, maxLines) {
|
||||
return baseLines == null || baseLines <= maxLines ? maxLines : baseLines;
|
||||
}
|
||||
|
||||
export function evaluateFileSize({ baseLines, candidateLines, maxLines }) {
|
||||
const limit = allowedLineCount(baseLines, maxLines);
|
||||
return { limit, violates: candidateLines > limit };
|
||||
}
|
||||
|
||||
function findRule(rules, relativePath) {
|
||||
return rules.find((rule) => relativePath.startsWith(`${rule.root}/`));
|
||||
}
|
||||
|
||||
export function resolveBaseRef(repoRoot, env = process.env) {
|
||||
if (env.CHECK_FILE_SIZES_BASE) {
|
||||
return env.CHECK_FILE_SIZES_BASE;
|
||||
}
|
||||
|
||||
if (env.GITHUB_ACTIONS === "true") {
|
||||
return "HEAD^1";
|
||||
}
|
||||
|
||||
try {
|
||||
const mergeBase = git(
|
||||
["merge-base", "origin/main", "HEAD"],
|
||||
repoRoot,
|
||||
).trim();
|
||||
const head = git(["rev-parse", "HEAD"], repoRoot).trim();
|
||||
return mergeBase === head ? "HEAD" : mergeBase;
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
"Could not resolve the file-size base from origin/main. Fetch origin/main or set CHECK_FILE_SIZES_BASE to an explicit commit.",
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function parseChangedFiles(output) {
|
||||
const fields = output.split("\0");
|
||||
const changes = [];
|
||||
|
||||
for (let index = 0; index < fields.length - 1; ) {
|
||||
const status = fields[index++];
|
||||
if (status.startsWith("R") || status.startsWith("C")) {
|
||||
changes.push({
|
||||
status: status[0],
|
||||
oldPath: fields[index++],
|
||||
path: fields[index++],
|
||||
});
|
||||
} else {
|
||||
changes.push({ status: status[0], path: fields[index++] });
|
||||
}
|
||||
}
|
||||
|
||||
return changes;
|
||||
}
|
||||
|
||||
function changedProjectFiles({ repoRoot, projectRelative, baseRef }) {
|
||||
const output = git(
|
||||
["diff", "--name-status", "-z", "-M", baseRef, "--", projectRelative],
|
||||
repoRoot,
|
||||
);
|
||||
const changes = parseChangedFiles(output);
|
||||
const trackedPaths = new Set(changes.map((change) => change.path));
|
||||
const untracked = git(
|
||||
["ls-files", "--others", "--exclude-standard", "-z", "--", projectRelative],
|
||||
repoRoot,
|
||||
)
|
||||
.split("\0")
|
||||
.filter(Boolean);
|
||||
|
||||
for (const filePath of untracked) {
|
||||
if (!trackedPaths.has(filePath)) {
|
||||
changes.push({ status: "A", path: filePath });
|
||||
}
|
||||
}
|
||||
return changes;
|
||||
}
|
||||
|
||||
function readBaseFile(repoRoot, baseRef, filePath) {
|
||||
return git(["show", `${baseRef}:${filePath}`], repoRoot, {
|
||||
encoding: null,
|
||||
}).toString("utf8");
|
||||
}
|
||||
|
||||
export async function runFileSizeCheck({ projectRoot, rules, label }) {
|
||||
// Every governed project is a direct child of the repository root. Derive
|
||||
// these paths without Git so hook-provided repository environment variables
|
||||
// cannot collapse the project pathspec to an empty string.
|
||||
const repoRoot = path.dirname(projectRoot);
|
||||
const projectRelative = toPosixPath(path.basename(projectRoot));
|
||||
const baseRef = resolveBaseRef(repoRoot);
|
||||
|
||||
// Fail clearly instead of silently turning a missing/shallow base into a pass.
|
||||
git(["cat-file", "-e", `${baseRef}^{commit}`], repoRoot);
|
||||
|
||||
const violations = [];
|
||||
for (const change of changedProjectFiles({
|
||||
repoRoot,
|
||||
projectRelative,
|
||||
baseRef,
|
||||
})) {
|
||||
if (change.status === "D") continue;
|
||||
|
||||
const relativePath = toPosixPath(
|
||||
path.relative(projectRelative, change.path),
|
||||
);
|
||||
const rule = findRule(rules, relativePath);
|
||||
if (!rule || !rule.extensions.has(path.extname(relativePath))) continue;
|
||||
|
||||
const candidatePath = path.join(repoRoot, change.path);
|
||||
const candidateLines = countLines(await fs.readFile(candidatePath, "utf8"));
|
||||
const basePath = change.oldPath ?? change.path;
|
||||
const baseContent =
|
||||
change.status === "A" ? null : readBaseFile(repoRoot, baseRef, basePath);
|
||||
const baseLines = baseContent == null ? null : countLines(baseContent);
|
||||
const result = evaluateFileSize({
|
||||
baseLines,
|
||||
candidateLines,
|
||||
maxLines: rule.maxLines,
|
||||
});
|
||||
|
||||
if (result.violates) {
|
||||
violations.push({
|
||||
relativePath,
|
||||
baseLines,
|
||||
candidateLines,
|
||||
limit: result.limit,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (violations.length === 0) return;
|
||||
|
||||
console.error(`${label} file size ratchet failed (base ${baseRef}):`);
|
||||
for (const violation of violations) {
|
||||
const before = violation.baseLines == null ? "new" : violation.baseLines;
|
||||
const delta =
|
||||
violation.baseLines == null
|
||||
? ""
|
||||
: ` (${violation.candidateLines - violation.baseLines >= 0 ? "+" : ""}${violation.candidateLines - violation.baseLines})`;
|
||||
console.error(
|
||||
`- ${violation.relativePath}: ${before} -> ${violation.candidateLines}${delta} lines (allowed ${violation.limit})`,
|
||||
);
|
||||
}
|
||||
console.error(
|
||||
"Keep new files at or below the limit; files already over it may not grow.",
|
||||
);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdtempSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
import {
|
||||
allowedLineCount,
|
||||
countLines,
|
||||
evaluateFileSize,
|
||||
parseChangedFiles,
|
||||
resolveBaseRef,
|
||||
} from "./check-file-sizes-core.mjs";
|
||||
|
||||
function git(repo, ...args) {
|
||||
return execFileSync("git", args, { cwd: repo, encoding: "utf8" }).trim();
|
||||
}
|
||||
|
||||
test("local base resolution uses the branch merge-base and fails without origin/main", () => {
|
||||
const repo = mkdtempSync(path.join(tmpdir(), "file-size-base-"));
|
||||
git(repo, "init", "-b", "main");
|
||||
git(repo, "config", "user.name", "Test");
|
||||
git(repo, "config", "user.email", "test@example.com");
|
||||
git(repo, "commit", "--allow-empty", "-m", "base");
|
||||
git(repo, "remote", "add", "origin", repo);
|
||||
git(repo, "fetch", "origin", "main:refs/remotes/origin/main");
|
||||
const base = git(repo, "rev-parse", "HEAD");
|
||||
git(repo, "switch", "-c", "feature");
|
||||
git(repo, "commit", "--allow-empty", "-m", "first branch commit");
|
||||
git(repo, "commit", "--allow-empty", "-m", "second branch commit");
|
||||
|
||||
assert.equal(resolveBaseRef(repo, {}), base);
|
||||
git(repo, "update-ref", "-d", "refs/remotes/origin/main");
|
||||
assert.throws(
|
||||
() => resolveBaseRef(repo, {}),
|
||||
/Fetch origin\/main or set CHECK_FILE_SIZES_BASE/,
|
||||
);
|
||||
});
|
||||
|
||||
test("counts empty, LF, and CRLF content with the existing semantics", () => {
|
||||
assert.equal(countLines(""), 0);
|
||||
assert.equal(countLines("one\n"), 2);
|
||||
assert.equal(countLines("one\r\ntwo"), 2);
|
||||
});
|
||||
|
||||
test("new files use the configured ceiling", () => {
|
||||
assert.equal(allowedLineCount(null, 1000), 1000);
|
||||
assert.deepEqual(
|
||||
evaluateFileSize({ baseLines: null, candidateLines: 1000, maxLines: 1000 }),
|
||||
{
|
||||
limit: 1000,
|
||||
violates: false,
|
||||
},
|
||||
);
|
||||
assert.equal(
|
||||
evaluateFileSize({ baseLines: null, candidateLines: 1001, maxLines: 1000 })
|
||||
.violates,
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("a compliant file may not cross the ceiling", () => {
|
||||
assert.equal(
|
||||
evaluateFileSize({ baseLines: 996, candidateLines: 1000, maxLines: 1000 })
|
||||
.violates,
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
evaluateFileSize({ baseLines: 996, candidateLines: 1003, maxLines: 1000 })
|
||||
.violates,
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("parses modifications, deletions, and renames from Git's NUL format", () => {
|
||||
assert.deepEqual(
|
||||
parseChangedFiles(
|
||||
"M\0desktop/src/a.ts\0D\0desktop/src/b.ts\0R100\0desktop/src/old.ts\0desktop/src/new.ts\0",
|
||||
),
|
||||
[
|
||||
{ status: "M", path: "desktop/src/a.ts" },
|
||||
{ status: "D", path: "desktop/src/b.ts" },
|
||||
{
|
||||
status: "R",
|
||||
oldPath: "desktop/src/old.ts",
|
||||
path: "desktop/src/new.ts",
|
||||
},
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test("an inherited oversized file may hold or shrink but not grow", () => {
|
||||
assert.equal(allowedLineCount(1026, 1000), 1026);
|
||||
assert.equal(
|
||||
evaluateFileSize({ baseLines: 1026, candidateLines: 1026, maxLines: 1000 })
|
||||
.violates,
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
evaluateFileSize({ baseLines: 1026, candidateLines: 1001, maxLines: 1000 })
|
||||
.violates,
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
evaluateFileSize({ baseLines: 1026, candidateLines: 1027, maxLines: 1000 })
|
||||
.violates,
|
||||
true,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
if [[ $# -ne 1 ]]; then
|
||||
echo "Usage: $0 <markdown-file>" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
MARKDOWN_FILE="$1"
|
||||
|
||||
if [[ ! -f "$MARKDOWN_FILE" ]]; then
|
||||
echo "error: markdown file not found: $MARKDOWN_FILE" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Buzz/relay media URLs often render in Buzz but fail in GitHub PR markdown
|
||||
# because GitHub's Camo proxy fetches them anonymously. PR screenshots should
|
||||
# be hosted through scripts/post-screenshots.sh or another GitHub-safe host.
|
||||
relay_media_pattern='https?://[^][()<>[:space:]"'"'"']*/media/[0-9a-fA-F]{64}\.(png|jpe?g|webp|gif)'
|
||||
sprout_media_pattern='https?://sprout-oss[^][()<>[:space:]"'"'"']*/media/'
|
||||
|
||||
tmp_matches=$(mktemp)
|
||||
trap 'rm -f "$tmp_matches"' EXIT
|
||||
|
||||
if grep -nE "$relay_media_pattern" "$MARKDOWN_FILE" >>"$tmp_matches"; then
|
||||
:
|
||||
fi
|
||||
if grep -nE "$sprout_media_pattern" "$MARKDOWN_FILE" >>"$tmp_matches"; then
|
||||
:
|
||||
fi
|
||||
|
||||
if [[ -s "$tmp_matches" ]]; then
|
||||
matches=$(sort -u "$tmp_matches")
|
||||
echo "error: PR markdown contains Buzz/relay media URLs that may not render on GitHub:" >&2
|
||||
printf '%s\n' "$matches" >&2
|
||||
echo >&2
|
||||
echo "Upload screenshots with scripts/post-screenshots.sh, then use its GitHub-safe image URLs in the PR body/comment." >&2
|
||||
exit 1
|
||||
fi
|
||||
@@ -0,0 +1,108 @@
|
||||
import { promises as fs } from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
/**
|
||||
* Shared "no hand-rolled pubkey truncation" guard.
|
||||
*
|
||||
* A truncated pubkey prefix is forgeable by vanity-grinding, so display
|
||||
* truncation must be consistent and centralized: the canonical
|
||||
* `truncatePubkey` in `shared/lib/pubkey.ts` (or the `<PubKey>` component,
|
||||
* which also offers full-key reveal + copy). Ad-hoc `pubkey.slice(0, N)`
|
||||
* display forms fragmented into five formats before this guard existed.
|
||||
*
|
||||
* It flags `.slice(` / `.substring(` / `.slice(0` template-truncations applied
|
||||
* to identifiers that look like a pubkey/npub, outside the canonical module.
|
||||
* Non-display uses (array windows, color derivation from a key, avatar
|
||||
* initials) live in each app's `overrides` allowlist.
|
||||
*/
|
||||
|
||||
const PUBKEY_SLICE_RE =
|
||||
/\b[A-Za-z_$][\w$]*(?:[Pp]ubkey|[Pp]ub_key|[Nn]pub)[\w$]*\??\.(?:slice|substring)\(|\b(?:pubkey|npub)\??\.(?:slice|substring)\(/g;
|
||||
|
||||
async function walkFiles(directory) {
|
||||
const entries = await fs.readdir(directory, { withFileTypes: true });
|
||||
const files = await Promise.all(
|
||||
entries.map(async (entry) => {
|
||||
const fullPath = path.join(directory, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
return walkFiles(fullPath);
|
||||
}
|
||||
return [fullPath];
|
||||
}),
|
||||
);
|
||||
return files.flat();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object} options
|
||||
* @param {string} options.projectRoot Absolute path the rule roots resolve against.
|
||||
* @param {Array<{root: string, extensions: Set<string>}>} options.rules Where to scan.
|
||||
* @param {string} options.label Human label for the failure header.
|
||||
* @param {Set<string>} [options.overrides] Allowlisted "relativePath:lineNumber" entries.
|
||||
* @param {Set<string>} [options.allowedFiles] Relative paths allowed to truncate (the canonical module).
|
||||
* @param {string} options.scriptPath Path mentioned in the failure hint.
|
||||
*/
|
||||
export async function runPubkeyTruncationCheck({
|
||||
projectRoot,
|
||||
rules,
|
||||
label,
|
||||
overrides = new Set(),
|
||||
allowedFiles = new Set(),
|
||||
scriptPath,
|
||||
}) {
|
||||
const candidateFiles = (
|
||||
await Promise.all(
|
||||
rules.map((rule) => {
|
||||
const dir = path.join(projectRoot, rule.root);
|
||||
return fs
|
||||
.access(dir)
|
||||
.then(() => walkFiles(dir))
|
||||
.catch(() => []);
|
||||
}),
|
||||
)
|
||||
).flat();
|
||||
|
||||
const violations = [];
|
||||
|
||||
for (const filePath of candidateFiles) {
|
||||
const relativePath = path.relative(projectRoot, filePath);
|
||||
const rule = rules.find((r) =>
|
||||
relativePath.startsWith(`${r.root}${path.sep}`),
|
||||
);
|
||||
if (!rule || !rule.extensions.has(path.extname(filePath))) {
|
||||
continue;
|
||||
}
|
||||
if (allowedFiles.has(relativePath.split(path.sep).join("/"))) {
|
||||
continue;
|
||||
}
|
||||
if (relativePath.includes(".test.")) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const content = await fs.readFile(filePath, "utf8");
|
||||
const lines = content.split("\n");
|
||||
lines.forEach((line, index) => {
|
||||
PUBKEY_SLICE_RE.lastIndex = 0;
|
||||
if (!PUBKEY_SLICE_RE.test(line)) {
|
||||
return;
|
||||
}
|
||||
const key = `${relativePath.split(path.sep).join("/")}:${index + 1}`;
|
||||
if (overrides.has(key)) {
|
||||
return;
|
||||
}
|
||||
violations.push({ key, line: line.trim() });
|
||||
});
|
||||
}
|
||||
|
||||
if (violations.length > 0) {
|
||||
console.error(
|
||||
`${label}: found ${violations.length} hand-rolled pubkey truncation(s).\n` +
|
||||
`Use \`truncatePubkey\` from shared/lib/pubkey (or the <PubKey> component) instead.\n` +
|
||||
`Genuine non-display uses can be allowlisted in ${scriptPath}.\n`,
|
||||
);
|
||||
for (const violation of violations) {
|
||||
console.error(` ${violation.key}: ${violation.line}`);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import { promises as fs } from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
/**
|
||||
* Shared "no hardcoded px text size" guard.
|
||||
*
|
||||
* Zoom (Cmd +/-) scales the root <html> font-size, so only **rem**-based text
|
||||
* scales. Hardcoded px text sizes (`text-[15px]`, `font-size: 15px`) freeze
|
||||
* against zoom — that's the timeline regression we fixed. This guard stops new
|
||||
* px text sizes from creeping back in. Use a rem-based Tailwind token instead
|
||||
* (e.g. the stock `text-base`, `text-sm`, `text-xs` scale — chat === base).
|
||||
*
|
||||
* It flags:
|
||||
* - Tailwind arbitrary text-size utilities: `text-[NNpx]`, `text-[N.NNrem]`,
|
||||
* `text-[N.NNem]` — any arbitrary font-size literal. Use a named token
|
||||
* (`text-2xs`, `text-3xs`, or the stock `text-base`/`text-sm`/`text-xs`
|
||||
* scale) so the size lives in `tailwind.config.js` as rem and stays
|
||||
* consistent. px literals freeze against zoom; arbitrary rem literals
|
||||
* re-fragment the scale we just consolidated.
|
||||
* - CSS px font sizes: `font-size: NNpx`
|
||||
*
|
||||
* Decorative/chrome exceptions (avatar initials sized to a fixed avatar box,
|
||||
* the `text-[6rem]` emoji glyph, etc.) live in the `overrides` allowlist
|
||||
* supplied by each app.
|
||||
*/
|
||||
|
||||
// Any arbitrary Tailwind text-size literal — px, rem, or em. Color literals
|
||||
// like `text-[#fff]` or `text-[var(--x)]` don't match (no unit-bearing number).
|
||||
const TEXT_ARBITRARY_RE = /\btext-\[\d+(?:\.\d+)?(?:px|rem|em)\]/g;
|
||||
// Match the CSS `font-size` property, but NOT custom properties like
|
||||
// `--font-size:` (third-party widget vars) which merely contain the substring.
|
||||
const FONT_SIZE_PX_RE = /(?<!-)\bfont-size:\s*\d+(?:\.\d+)?px/g;
|
||||
|
||||
async function walkFiles(directory) {
|
||||
const entries = await fs.readdir(directory, { withFileTypes: true });
|
||||
const files = await Promise.all(
|
||||
entries.map(async (entry) => {
|
||||
const fullPath = path.join(directory, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
return walkFiles(fullPath);
|
||||
}
|
||||
return [fullPath];
|
||||
}),
|
||||
);
|
||||
return files.flat();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object} options
|
||||
* @param {string} options.projectRoot Absolute path the rule roots resolve against.
|
||||
* @param {Array<{root: string, extensions: Set<string>}>} options.rules Where to scan.
|
||||
* @param {string} options.label Human label for the failure header.
|
||||
* @param {Set<string>} [options.overrides] Allowlisted "relativePath:matchedLiteral" entries.
|
||||
* @param {string} options.scriptPath Path mentioned in the failure hint.
|
||||
*/
|
||||
export async function runPxTextCheck({
|
||||
projectRoot,
|
||||
rules,
|
||||
label,
|
||||
overrides = new Set(),
|
||||
scriptPath,
|
||||
}) {
|
||||
const candidateFiles = (
|
||||
await Promise.all(
|
||||
rules.map((rule) => {
|
||||
const dir = path.join(projectRoot, rule.root);
|
||||
return fs
|
||||
.access(dir)
|
||||
.then(() => walkFiles(dir))
|
||||
.catch(() => []);
|
||||
}),
|
||||
)
|
||||
).flat();
|
||||
|
||||
const violations = [];
|
||||
|
||||
for (const filePath of candidateFiles) {
|
||||
// `rules[].root` and the `overrides` keys are authored with `/`, but
|
||||
// path.relative yields `\` on Windows — so every comparison against them
|
||||
// has to happen in posix form or it silently matches nothing.
|
||||
const relativePath = path
|
||||
.relative(projectRoot, filePath)
|
||||
.split(path.sep)
|
||||
.join("/");
|
||||
const rule = rules.find((r) => relativePath.startsWith(`${r.root}/`));
|
||||
if (!rule) {
|
||||
continue;
|
||||
}
|
||||
if (!rule.extensions.has(path.extname(relativePath))) {
|
||||
continue;
|
||||
}
|
||||
// Optional per-rule basename allowlist — scopes the scan to specific files.
|
||||
if (rule.files && !rule.files.has(path.basename(relativePath))) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const content = await fs.readFile(filePath, "utf8");
|
||||
const lines = content.split(/\r?\n/);
|
||||
lines.forEach((line, index) => {
|
||||
const lineNumber = index + 1;
|
||||
const matches = [
|
||||
...(line.match(TEXT_ARBITRARY_RE) ?? []),
|
||||
...(line.match(FONT_SIZE_PX_RE) ?? []),
|
||||
];
|
||||
for (const match of matches) {
|
||||
const key = `${relativePath}:${match}`;
|
||||
if (!overrides.has(key)) {
|
||||
violations.push({ relativePath, lineNumber, match });
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (violations.length > 0) {
|
||||
console.error(`${label} px-text check failed:`);
|
||||
for (const v of violations) {
|
||||
console.error(`- ${v.relativePath}:${v.lineNumber}: ${v.match}`);
|
||||
}
|
||||
console.error(
|
||||
"Use a rem-based Tailwind text token (e.g. the stock `text-base`, " +
|
||||
"`text-sm`, `text-xs` scale, or the `text-2xs` / `text-3xs` meta-text " +
|
||||
"tokens) so the text scales with Cmd +/- zoom and stays on one scale. " +
|
||||
"If this size is " +
|
||||
"genuinely decorative/chrome (not readable message text), add a " +
|
||||
`narrowly scoped \`relativePath:matchedLiteral\` exception in \`${scriptPath}\`.`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
#!/usr/bin/env bash
|
||||
# =============================================================================
|
||||
# ci-mesh-lifecycle-smoke.sh — relay-driven mesh lifecycle smoke
|
||||
# =============================================================================
|
||||
# Provisions a membership-gated buzz-relay and runs the full relay-driven
|
||||
# mesh lifecycle harness (crates/buzz-relay/examples/mesh_relay_lifecycle_smoke.rs):
|
||||
# membership → signed discovery notes → relay-derived allowlist → join →
|
||||
# inference over QUIC → stranger denied.
|
||||
#
|
||||
# Mirrors the shape of mesh-llm's own CI smoke scripts (single runner, tiny
|
||||
# CPU model, real multi-process mesh), but with the Buzz relay as the control
|
||||
# plane instead of a hand-carried invite token.
|
||||
#
|
||||
# Usage:
|
||||
# ./scripts/ci-mesh-lifecycle-smoke.sh [--profile <cargo-profile>] [--no-build]
|
||||
#
|
||||
# Env:
|
||||
# MESH_SMOKE_MODEL Override the served model ref (default: SmolLM2-135M).
|
||||
# =============================================================================
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
||||
cd "${REPO_ROOT}"
|
||||
|
||||
CARGO_PROFILE="${CARGO_PROFILE:-ci}"
|
||||
SKIP_BUILD=false
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--profile) CARGO_PROFILE="$2"; shift 2 ;;
|
||||
--no-build) SKIP_BUILD=true; shift ;;
|
||||
*) echo "Unknown option: $1" >&2; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
BLUE='\033[0;34m'
|
||||
GREEN='\033[0;32m'
|
||||
RED='\033[0;31m'
|
||||
NC='\033[0m'
|
||||
log() { echo -e "${BLUE}[mesh-lifecycle]${NC} $*"; }
|
||||
ok() { echo -e "${GREEN}[mesh-lifecycle]${NC} $*"; }
|
||||
err() { echo -e "${RED}[mesh-lifecycle]${NC} $*" >&2; }
|
||||
|
||||
# ── Build ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
if [[ "${SKIP_BUILD}" == "true" ]]; then
|
||||
log "Skipping build (--no-build)"
|
||||
else
|
||||
log "Building relay, admin CLI, and lifecycle harness (profile: ${CARGO_PROFILE})..."
|
||||
cargo build --profile "${CARGO_PROFILE}" -p buzz-relay -p buzz-admin -p git-credential-nostr
|
||||
cargo build --profile "${CARGO_PROFILE}" -p buzz-relay --example mesh_relay_lifecycle_smoke
|
||||
fi
|
||||
|
||||
ADMIN_BIN="target/${CARGO_PROFILE}/buzz-admin"
|
||||
HARNESS_BIN="target/${CARGO_PROFILE}/examples/mesh_relay_lifecycle_smoke"
|
||||
for bin in "${ADMIN_BIN}" "${HARNESS_BIN}" "target/${CARGO_PROFILE}/buzz-relay"; do
|
||||
if [[ ! -x "${bin}" ]]; then
|
||||
err "Missing binary: ${bin}"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
# ── Relay identity + membership gating ───────────────────────────────────────
|
||||
# The relay owner and signing key are throwaway CI identities. RELAY_OWNER
|
||||
# never participates in the mesh; it only satisfies the NIP-43 requirement
|
||||
# that a membership-gated relay has an administrable owner.
|
||||
|
||||
log "Generating relay owner + signing identities..."
|
||||
read_keys() { "${ADMIN_BIN}" generate-key 2>/dev/null; }
|
||||
OWNER_OUT="$(read_keys)"
|
||||
RELAY_OWNER_PUBKEY="$(echo "${OWNER_OUT}" | awk '/Public key:/ {print $3}')"
|
||||
SIGNER_OUT="$(read_keys)"
|
||||
BUZZ_RELAY_PRIVATE_KEY="$(echo "${SIGNER_OUT}" | awk '/Secret key:/ {print $3}')"
|
||||
if [[ -z "${RELAY_OWNER_PUBKEY}" || -z "${BUZZ_RELAY_PRIVATE_KEY}" ]]; then
|
||||
err "Failed to generate relay identities via buzz-admin generate-key"
|
||||
exit 1
|
||||
fi
|
||||
export BUZZ_REQUIRE_RELAY_MEMBERSHIP=true
|
||||
export RELAY_OWNER_PUBKEY
|
||||
export BUZZ_RELAY_PRIVATE_KEY
|
||||
|
||||
# ── Start the membership-gated relay ─────────────────────────────────────────
|
||||
# A stale relay on :3000 would pass the readiness poll while silently running
|
||||
# WITHOUT membership gating — the stranger-denied assertion would then fail
|
||||
# (or worse, an open relay would mask a real gating regression). Fail fast.
|
||||
if lsof -nP -iTCP:3000 -sTCP:LISTEN >/dev/null 2>&1; then
|
||||
err "Port 3000 is already in use — stop the existing relay first (its config would not be membership-gated)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
log "Starting membership-gated relay..."
|
||||
CARGO_PROFILE="${CARGO_PROFILE}" ./scripts/start-relay-for-tests.sh --no-build
|
||||
|
||||
cleanup() {
|
||||
log "Stopping relay..."
|
||||
if [[ -f /tmp/buzz-relay.pid ]]; then
|
||||
kill "$(cat /tmp/buzz-relay.pid)" 2>/dev/null || true
|
||||
fi
|
||||
# The harness kills its own children; sweep any stragglers from a hard fail.
|
||||
pkill -f mesh_relay_lifecycle_smoke 2>/dev/null || true
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
# ── Run the lifecycle harness ────────────────────────────────────────────────
|
||||
|
||||
log "Running relay-driven mesh lifecycle smoke..."
|
||||
RELAY_URL=ws://localhost:3000 \
|
||||
DATABASE_URL=postgres://buzz:buzz_dev@localhost:5432/buzz \
|
||||
REDIS_URL=redis://localhost:6379 \
|
||||
BUZZ_ADMIN_BIN="${ADMIN_BIN}" \
|
||||
"${HARNESS_BIN}"
|
||||
|
||||
ok "Relay-driven mesh lifecycle smoke passed"
|
||||
@@ -0,0 +1,59 @@
|
||||
#!/usr/bin/env bash
|
||||
# Reap the agent processes belonging to a single desktop instance.
|
||||
#
|
||||
# `tauri dev` Ctrl+C tears down the Rust app before its in-process system sweep
|
||||
# can finish, so agent workers (goose, buzz-agent, ...) it spawned in their
|
||||
# own process groups survive as orphans. This script is the shell-side backstop:
|
||||
# run it from an EXIT trap in the `just dev`/`just staging` recipes.
|
||||
#
|
||||
# It reads the PID-file receipts the desktop already writes — one file per agent
|
||||
# under `<app-data>/agents/agent-pids/<pubkey>.pid`, each containing the agent's
|
||||
# PGID (agents are spawned with `process_group(0)`, so PID == PGID). Killing by
|
||||
# PGID reaches the whole agent subtree. We deliberately do NOT match the
|
||||
# `BUZZ_MANAGED_AGENT` env var from the shell: on macOS `pkill -f` matches only
|
||||
# argv, not the environment, so an env-marker match silently reaps nothing.
|
||||
#
|
||||
# Scoping is exact because the app-data directory is keyed by the instance's
|
||||
# bundle identifier, so this only ever touches the receipts this instance wrote
|
||||
# (the main checkout never reaps a worktree's agents, or vice versa).
|
||||
#
|
||||
# Usage: cleanup-instance-agents.sh <instance-id>
|
||||
# <instance-id> is the desktop bundle identifier, e.g. `xyz.block.buzz.app.dev`
|
||||
# (main checkout) or `xyz.block.buzz.app.dev.my-branch` (a worktree).
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
instance_id="${1:-}"
|
||||
if [[ -z "$instance_id" ]]; then
|
||||
echo "cleanup-instance-agents: no instance id given, skipping" >&2
|
||||
exit 0
|
||||
fi
|
||||
|
||||
case "$(uname -s)" in
|
||||
Darwin) app_data="$HOME/Library/Application Support/$instance_id" ;;
|
||||
*) app_data="${XDG_DATA_HOME:-$HOME/.local/share}/$instance_id" ;;
|
||||
esac
|
||||
|
||||
pids_dir="$app_data/agents/agent-pids"
|
||||
[[ -d "$pids_dir" ]] || exit 0
|
||||
|
||||
shopt -s nullglob
|
||||
pgids=()
|
||||
for pid_file in "$pids_dir"/*.pid; do
|
||||
pgid="$(<"$pid_file")"
|
||||
pgid="${pgid//[$'\t\r\n ']/}"
|
||||
[[ "$pgid" =~ ^[0-9]+$ ]] || continue
|
||||
pgids+=("$pgid")
|
||||
done
|
||||
[[ ${#pgids[@]} -gt 0 ]] || exit 0
|
||||
|
||||
# SIGTERM the whole group first, give it a moment, then SIGKILL survivors.
|
||||
# `kill -- -<pgid>` targets the process group. Failures are expected and fine
|
||||
# (already-dead group, recycled PGID owned by someone else we can't signal).
|
||||
for pgid in "${pgids[@]}"; do
|
||||
kill -TERM -- "-$pgid" 2>/dev/null || true
|
||||
done
|
||||
sleep 0.2
|
||||
for pgid in "${pgids[@]}"; do
|
||||
kill -KILL -- "-$pgid" 2>/dev/null || true
|
||||
done
|
||||
@@ -0,0 +1,290 @@
|
||||
-- scripts/cutover/1321_backfill_default_community.sql
|
||||
--
|
||||
-- One-off cutover: single-community (pre-1321) Postgres -> multi-tenant (1321)
|
||||
-- schema. Assigns EVERY existing row to one default community derived from the
|
||||
-- deployment host.
|
||||
--
|
||||
-- ─────────────────────────────────────────────────────────────────────────────
|
||||
-- STRATEGY: rename pre-1321 objects aside into a `legacy` schema, rebuild the
|
||||
-- correct 1321 schema in `public` from 0001 VERBATIM, copy data
|
||||
-- forward, drop `legacy`.
|
||||
--
|
||||
-- Why not an in-place ALTER ("just add community_id and re-key")? The 1321
|
||||
-- schema differs from pre-1321 by far MORE than a prepended community_id, and
|
||||
-- hand-transcribing the difference is how silent isolation defects get in (local
|
||||
-- validation caught an in-place draft that left 5 tables single-tenant-keyed and
|
||||
-- dropped ~13 FKs). The real delta:
|
||||
-- * channels gains ttl_seconds, ttl_deadline
|
||||
-- * events gains not_before, delivered_at, search_tsv (GENERATED) + GIN
|
||||
-- * EVERY scoped table is re-keyed to a community-leading PK/UNIQUE
|
||||
-- * EVERY hot-path index is replaced with a community-leading one
|
||||
-- * channels gains a community_id immutability trigger
|
||||
-- * 6 brand-new tables (communities, scheduled_workflow_fires, relay_members,
|
||||
-- archived_identities, audit_log, _operator_global_tables)
|
||||
--
|
||||
-- So we do NOT describe the target by hand. We let 1321's own
|
||||
-- migrations/0001_initial_schema.sql define it, verbatim, into a clean `public`.
|
||||
-- The result is structurally identical to a fresh 1321 DB BY CONSTRUCTION.
|
||||
--
|
||||
-- The one obstacle to running the from-scratch 0001 against an existing DB is
|
||||
-- name collisions: 0001 issues bare `CREATE TYPE <enum>` (Postgres has no
|
||||
-- CREATE TYPE IF NOT EXISTS) and `CREATE TABLE <name>`. We clear `public` of
|
||||
-- those names first by renaming the pre-1321 tables AND their enum types into a
|
||||
-- `legacy` schema. `CREATE EXTENSION IF NOT EXISTS pgcrypto` in 0001 is a no-op
|
||||
-- (idempotent), and pgcrypto stays in public untouched.
|
||||
--
|
||||
-- Phases (all in ONE transaction; all-or-nothing):
|
||||
-- A CREATE SCHEMA legacy; move every pre-1321 table + enum type into it
|
||||
-- B \i the repo's 0001 -> full correct 1321 schema in clean public
|
||||
-- C create the one default community from :host
|
||||
-- D INSERT ... SELECT default_community, <explicit cols> from each legacy.*
|
||||
-- (enum cols cast ::text::public.<enum>; search_tsv regenerates; new
|
||||
-- columns default NULL)
|
||||
-- E DROP SCHEMA legacy CASCADE
|
||||
-- ─────────────────────────────────────────────────────────────────────────────
|
||||
--
|
||||
-- PRECONDITIONS
|
||||
-- * DB is the pre-1321 single-community schema (no community_id columns, no
|
||||
-- `communities` table). Guard below refuses to run otherwise.
|
||||
-- * Run psql with this script via -f (not piped on stdin): the Phase B
|
||||
-- include below uses \ir, which resolves relative to THIS file, so the
|
||||
-- repo's migrations/0001 is found regardless of the operator's cwd.
|
||||
-- * TAKE A pg_dump / PVC SNAPSHOT FIRST. No down-path; the snapshot IS the
|
||||
-- rollback.
|
||||
--
|
||||
-- USAGE (run from anywhere; \ir below resolves the schema include relative to
|
||||
-- this file, so cwd does not matter)
|
||||
-- psql "$DATABASE_URL" \
|
||||
-- -v host="'sprout-oss.stage.blox.sqprod.co'" \
|
||||
-- -v ON_ERROR_STOP=1 \
|
||||
-- -f scripts/cutover/1321_backfill_default_community.sql
|
||||
--
|
||||
-- After commit: boot the 1321 relay with BUZZ_AUTO_MIGRATE=false. The schema is
|
||||
-- already correct; the relay's idempotent boot-time
|
||||
-- ensure_configured_community / allowlist->relay_members backfill finds this
|
||||
-- community and no-ops. (If you instead want the relay's sqlx migrator to
|
||||
-- consider 0001 "applied", seed its _sqlx_migrations row — see the runbook;
|
||||
-- not required for correct operation.)
|
||||
|
||||
\set ON_ERROR_STOP on
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- ── Guard: refuse to run on anything but pristine pre-1321 ───────────────────
|
||||
DO $guard$
|
||||
BEGIN
|
||||
IF EXISTS (SELECT 1 FROM information_schema.tables
|
||||
WHERE table_schema='public' AND table_name='communities') THEN
|
||||
RAISE EXCEPTION
|
||||
'communities table already exists -- DB is not pristine pre-1321 '
|
||||
'(already migrated). Refusing to run.';
|
||||
END IF;
|
||||
IF EXISTS (SELECT 1 FROM information_schema.columns
|
||||
WHERE table_schema='public' AND column_name='community_id') THEN
|
||||
RAISE EXCEPTION
|
||||
'a community_id column already exists -- DB is not pristine pre-1321. '
|
||||
'Refusing to run.';
|
||||
END IF;
|
||||
IF EXISTS (SELECT 1 FROM information_schema.schemata
|
||||
WHERE schema_name='legacy') THEN
|
||||
RAISE EXCEPTION
|
||||
'schema legacy already exists -- leftover from a failed run. '
|
||||
'Drop it (DROP SCHEMA legacy CASCADE) and retry.';
|
||||
END IF;
|
||||
END
|
||||
$guard$;
|
||||
|
||||
-- ── Phase A: move pre-1321 tables + enum types into `legacy` ─────────────────
|
||||
CREATE SCHEMA legacy;
|
||||
|
||||
-- Tables (partitioned parents move with their partitions via SET SCHEMA on the
|
||||
-- parent? -- no: SET SCHEMA does NOT move partition children. So we move the
|
||||
-- parent AND each partition. Simpler and safe: move every base/partitioned/
|
||||
-- partition relation owned in public that is one of our pre-1321 tables.)
|
||||
DO $move_tables$
|
||||
DECLARE r RECORD;
|
||||
BEGIN
|
||||
FOR r IN
|
||||
SELECT c.relname
|
||||
FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
|
||||
WHERE n.nspname = 'public'
|
||||
AND c.relkind IN ('r','p') -- ordinary + partitioned parents
|
||||
ORDER BY c.relkind DESC -- move partition children before parents? not needed
|
||||
LOOP
|
||||
EXECUTE format('ALTER TABLE public.%I SET SCHEMA legacy', r.relname);
|
||||
END LOOP;
|
||||
END
|
||||
$move_tables$;
|
||||
|
||||
-- Enum types (must leave public so 0001 can CREATE TYPE with the same names).
|
||||
DO $move_types$
|
||||
DECLARE r RECORD;
|
||||
BEGIN
|
||||
FOR r IN
|
||||
SELECT t.typname
|
||||
FROM pg_type t JOIN pg_namespace n ON n.oid = t.typnamespace
|
||||
WHERE n.nspname = 'public' AND t.typtype = 'e'
|
||||
LOOP
|
||||
EXECUTE format('ALTER TYPE public.%I SET SCHEMA legacy', r.typname);
|
||||
END LOOP;
|
||||
END
|
||||
$move_types$;
|
||||
|
||||
-- ── Phase B: the authoritative 1321 schema, verbatim, into clean public ──────
|
||||
-- \ir = include relative to THIS script's directory (scripts/cutover/), so the
|
||||
-- repo's migrations/0001 resolves regardless of the operator's cwd.
|
||||
\ir ../../migrations/0001_initial_schema.sql
|
||||
|
||||
-- ── Phase C: the one default community, normalized like the relay does ───────
|
||||
INSERT INTO public.communities (host)
|
||||
VALUES (rtrim(lower(:host), '.'));
|
||||
SELECT id AS default_community
|
||||
FROM public.communities
|
||||
WHERE lower(host) = rtrim(lower(:host), '.') \gset
|
||||
|
||||
-- ── Phase D: copy data forward ───────────────────────────────────────────────
|
||||
-- Source legacy.*, target public.*. Enum columns cross a type boundary
|
||||
-- (legacy.<enum> -> public.<enum>); same labels, so ::text::public.<enum> is
|
||||
-- lossless. search_tsv omitted (GENERATED); new-in-1321 columns omitted (NULL).
|
||||
|
||||
INSERT INTO public.channels
|
||||
(community_id, id, name, channel_type, visibility, description, canvas,
|
||||
created_by, created_at, updated_at, archived_at, deleted_at, nip29_group_id,
|
||||
topic_required, max_members, topic, topic_set_by, topic_set_at, purpose,
|
||||
purpose_set_by, purpose_set_at, participant_hash)
|
||||
SELECT :'default_community', id, name,
|
||||
channel_type::text::public.channel_type,
|
||||
visibility::text::public.channel_visibility,
|
||||
description, canvas, created_by, created_at, updated_at, archived_at,
|
||||
deleted_at, nip29_group_id, topic_required, max_members, topic, topic_set_by,
|
||||
topic_set_at, purpose, purpose_set_by, purpose_set_at, participant_hash
|
||||
FROM legacy.channels;
|
||||
|
||||
INSERT INTO public.channel_members
|
||||
(community_id, channel_id, pubkey, role, joined_at, invited_by, removed_at,
|
||||
removed_by, hidden_at)
|
||||
SELECT :'default_community', channel_id, pubkey,
|
||||
role::text::public.member_role,
|
||||
joined_at, invited_by, removed_at, removed_by, hidden_at
|
||||
FROM legacy.channel_members;
|
||||
|
||||
INSERT INTO public.users
|
||||
(community_id, pubkey, nip05_handle, display_name, avatar_url, about,
|
||||
agent_type, capabilities, okta_user_id, created_at, updated_at,
|
||||
deactivated_at, metadata_event_id, agent_owner_pubkey, channel_add_policy)
|
||||
SELECT :'default_community', pubkey, nip05_handle, display_name, avatar_url, about,
|
||||
agent_type, capabilities, okta_user_id, created_at, updated_at,
|
||||
deactivated_at, metadata_event_id, agent_owner_pubkey,
|
||||
channel_add_policy::text::public.channel_add_policy
|
||||
FROM legacy.users;
|
||||
|
||||
INSERT INTO public.events
|
||||
(community_id, id, pubkey, created_at, kind, tags, content, sig,
|
||||
received_at, channel_id, deleted_at, d_tag)
|
||||
SELECT :'default_community', id, pubkey, created_at, kind, tags, content, sig,
|
||||
received_at, channel_id, deleted_at, d_tag
|
||||
FROM legacy.events;
|
||||
|
||||
INSERT INTO public.event_mentions
|
||||
(community_id, pubkey_hex, event_id, event_created_at, channel_id, event_kind)
|
||||
SELECT :'default_community', pubkey_hex, event_id, event_created_at, channel_id, event_kind
|
||||
FROM legacy.event_mentions;
|
||||
|
||||
INSERT INTO public.subscriptions
|
||||
(community_id, id, owner_pubkey, filter_kinds, filter_authors,
|
||||
filter_channel_ids, filter_since, filter_until, delivery_method,
|
||||
delivery_url, status, pause_reason, delivered_count, error_count,
|
||||
created_at, updated_at)
|
||||
SELECT :'default_community', id, owner_pubkey, filter_kinds, filter_authors,
|
||||
filter_channel_ids, filter_since, filter_until,
|
||||
delivery_method::text::public.delivery_method, delivery_url,
|
||||
status::text::public.subscription_status,
|
||||
pause_reason::text::public.pause_reason,
|
||||
delivered_count, error_count, created_at, updated_at
|
||||
FROM legacy.subscriptions;
|
||||
|
||||
-- delivery_log.id is GENERATED ALWAYS AS IDENTITY; preserve original ids.
|
||||
INSERT INTO public.delivery_log
|
||||
(community_id, id, subscription_id, event_id, method, delivered_at, success,
|
||||
http_status, error_message, attempt_number)
|
||||
OVERRIDING SYSTEM VALUE
|
||||
SELECT :'default_community', id, subscription_id, event_id,
|
||||
method::text::public.delivery_method, delivered_at, success,
|
||||
http_status, error_message, attempt_number
|
||||
FROM legacy.delivery_log;
|
||||
|
||||
INSERT INTO public.workflows
|
||||
(community_id, id, name, owner_pubkey, channel_id, definition,
|
||||
definition_hash, status, enabled, created_at, updated_at)
|
||||
SELECT :'default_community', id, name, owner_pubkey, channel_id, definition,
|
||||
definition_hash, status::text::public.workflow_status, enabled,
|
||||
created_at, updated_at
|
||||
FROM legacy.workflows;
|
||||
|
||||
INSERT INTO public.workflow_runs
|
||||
(community_id, id, workflow_id, status, trigger_event_id, current_step,
|
||||
execution_trace, trigger_context, started_at, completed_at, error_message,
|
||||
created_at)
|
||||
SELECT :'default_community', id, workflow_id,
|
||||
status::text::public.run_status, trigger_event_id, current_step,
|
||||
execution_trace, trigger_context, started_at, completed_at, error_message,
|
||||
created_at
|
||||
FROM legacy.workflow_runs;
|
||||
|
||||
INSERT INTO public.workflow_approvals
|
||||
(community_id, token, workflow_id, run_id, step_id, step_index, approver_spec,
|
||||
status, approver_pubkey, note, granted_at, denied_at, expires_at, created_at)
|
||||
SELECT :'default_community', token, workflow_id, run_id, step_id, step_index,
|
||||
approver_spec, status::text::public.approval_status, approver_pubkey,
|
||||
note, granted_at, denied_at, expires_at, created_at
|
||||
FROM legacy.workflow_approvals;
|
||||
|
||||
INSERT INTO public.api_tokens
|
||||
(community_id, id, token_hash, owner_pubkey, name, scopes, channel_ids,
|
||||
created_at, expires_at, last_used_at, revoked_at, revoked_by,
|
||||
created_by_self_mint)
|
||||
SELECT :'default_community', id, token_hash, owner_pubkey, name, scopes, channel_ids,
|
||||
created_at, expires_at, last_used_at, revoked_at, revoked_by,
|
||||
created_by_self_mint
|
||||
FROM legacy.api_tokens;
|
||||
|
||||
-- rate_limit_violations: OPERATOR-GLOBAL; community_id nullable attribution.
|
||||
-- id is IDENTITY; preserve.
|
||||
INSERT INTO public.rate_limit_violations
|
||||
(id, community_id, pubkey, violation_at, limit_type, limit_value,
|
||||
actual_value, action_taken)
|
||||
OVERRIDING SYSTEM VALUE
|
||||
SELECT id, :'default_community', pubkey, violation_at, limit_type, limit_value,
|
||||
actual_value, action_taken
|
||||
FROM legacy.rate_limit_violations;
|
||||
|
||||
INSERT INTO public.thread_metadata
|
||||
(community_id, event_created_at, event_id, channel_id, parent_event_id,
|
||||
parent_event_created_at, root_event_id, root_event_created_at, depth,
|
||||
reply_count, descendant_count, last_reply_at, broadcast)
|
||||
SELECT :'default_community', event_created_at, event_id, channel_id, parent_event_id,
|
||||
parent_event_created_at, root_event_id, root_event_created_at, depth,
|
||||
reply_count, descendant_count, last_reply_at, broadcast
|
||||
FROM legacy.thread_metadata;
|
||||
|
||||
INSERT INTO public.reactions
|
||||
(community_id, event_created_at, event_id, pubkey, emoji, created_at,
|
||||
removed_at, reaction_event_id)
|
||||
SELECT :'default_community', event_created_at, event_id, pubkey, emoji, created_at,
|
||||
removed_at, reaction_event_id
|
||||
FROM legacy.reactions;
|
||||
|
||||
INSERT INTO public.pubkey_allowlist
|
||||
(community_id, pubkey, added_by, added_at, note)
|
||||
SELECT :'default_community', pubkey, added_by, added_at, note
|
||||
FROM legacy.pubkey_allowlist;
|
||||
|
||||
-- ── Phase E: retire the pre-1321 objects ─────────────────────────────────────
|
||||
DROP SCHEMA legacy CASCADE;
|
||||
|
||||
COMMIT;
|
||||
|
||||
-- ── Post-commit sanity (run manually; not part of the txn) ───────────────────
|
||||
-- SELECT count(*) FROM communities; -- expect 1
|
||||
-- SELECT count(*) FROM events WHERE community_id IS NULL; -- expect 0
|
||||
-- SELECT to_regnamespace('legacy'); -- expect NULL (gone)
|
||||
@@ -0,0 +1,87 @@
|
||||
# 1321 cutover: single-community → multi-tenant
|
||||
|
||||
`1321_backfill_default_community.sql` is a **one-off operator script**, not a
|
||||
startup migration. It takes a pre-1321 (single-community) Buzz Postgres to the
|
||||
1321 multi-tenant schema, assigning every existing row to one default community
|
||||
derived from the deployment host.
|
||||
|
||||
> It is **not** embedded in the relay binary. The relay's `sqlx::migrate!`
|
||||
> embeds only `migrations/0001_initial_schema.sql` — the consolidated 1321
|
||||
> schema. Fresh deployments need only that; this script exists solely to carry
|
||||
> existing pre-1321 data across the rewrite. (It uses psql client features —
|
||||
> `\set`, `\ir`, `:'var'` interpolation, `\gset` — that the embedded sqlx
|
||||
> migrator cannot run, which is why it must live here and not under
|
||||
> `migrations/`.)
|
||||
|
||||
## When you need it
|
||||
|
||||
Only when upgrading a Postgres that already holds **pre-1321 single-community
|
||||
data** to 1321. A brand-new deployment does **not** run this — it provisions
|
||||
from `migrations/0001_initial_schema.sql` (or `schema/schema.sql`) directly.
|
||||
|
||||
## Preconditions
|
||||
|
||||
- The DB is pristine pre-1321: no `communities` table, no `community_id`
|
||||
columns, no leftover `legacy` schema. The script's guard **refuses to run**
|
||||
otherwise (already-migrated, partially-migrated, or failed-prior-run states
|
||||
all raise and abort — see the guard block at the top of the script).
|
||||
- **Take a `pg_dump` / PVC snapshot first.** There is no down-path. The
|
||||
snapshot **is** the rollback.
|
||||
|
||||
## Run it
|
||||
|
||||
The whole script runs in **one transaction** (all-or-nothing). Pass the
|
||||
deployment host as `:host`; it is normalized exactly as the relay normalizes a
|
||||
community host (`rtrim(lower(host), '.')`).
|
||||
|
||||
```sh
|
||||
psql "$DATABASE_URL" \
|
||||
-v host="'your-deployment-host.example.com'" \
|
||||
-v ON_ERROR_STOP=1 \
|
||||
-f scripts/cutover/1321_backfill_default_community.sql
|
||||
```
|
||||
|
||||
`-f` (not stdin) matters: the script `\ir`-includes
|
||||
`../../migrations/0001_initial_schema.sql` **relative to its own location**, so
|
||||
it resolves the canonical 1321 schema regardless of your cwd.
|
||||
|
||||
### What it does
|
||||
|
||||
1. Renames all pre-1321 tables and enum types aside into a `legacy` schema.
|
||||
2. `\ir`-includes the repo's `0001` verbatim → the correct 1321 schema in a
|
||||
clean `public` (structurally identical to a fresh 1321 DB **by
|
||||
construction** — no hand-transcription).
|
||||
3. Creates the one default community from `:host`.
|
||||
4. Copies every row forward, stamped with that `community_id` (`search_tsv`
|
||||
regenerates as GENERATED; new-in-1321 columns default NULL).
|
||||
5. `DROP SCHEMA legacy CASCADE`.
|
||||
|
||||
## After it commits
|
||||
|
||||
Boot the 1321 relay with **`BUZZ_AUTO_MIGRATE=false`**. The schema is already
|
||||
correct; the relay's idempotent boot-time `ensure_configured_community` /
|
||||
allowlist→relay_members backfill finds this community and no-ops.
|
||||
|
||||
If you want the relay's sqlx migrator to consider `0001` already applied (so a
|
||||
later `BUZZ_AUTO_MIGRATE=true` boot doesn't try to re-run it), seed its
|
||||
`_sqlx_migrations` row to match a fresh 1321 install:
|
||||
|
||||
```sql
|
||||
-- version 1, description 'initial schema'; checksum must match the embedded 0001.
|
||||
-- Easiest: diff _sqlx_migrations against a freshly-migrated 1321 DB and copy the row.
|
||||
```
|
||||
|
||||
This is **not** required for correct operation — a fresh `0001` run against the
|
||||
already-correct schema is a no-op only if the migrator's checksum matches, so
|
||||
prefer leaving `BUZZ_AUTO_MIGRATE=false` unless you have a reason to seed.
|
||||
|
||||
## Verify (post-commit, outside the txn)
|
||||
|
||||
```sql
|
||||
SELECT count(*) FROM communities; -- expect 1
|
||||
SELECT count(*) FROM events WHERE community_id IS NULL; -- expect 0
|
||||
SELECT to_regnamespace('legacy'); -- expect NULL (gone)
|
||||
```
|
||||
|
||||
For a stronger guarantee, take a constraint/trigger/index/column diff of this
|
||||
DB against a freshly-migrated 1321 DB — it should be empty.
|
||||
@@ -0,0 +1,34 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
platform=${1:?usage: desktop-native-toolchain-id.sh <macos|linux|windows>}
|
||||
case "$platform" in
|
||||
macos)
|
||||
{
|
||||
sw_vers
|
||||
xcodebuild -version
|
||||
xcrun --sdk macosx --show-sdk-path
|
||||
xcrun --sdk macosx --show-sdk-version
|
||||
xcrun clang --version
|
||||
} ;;
|
||||
linux)
|
||||
{
|
||||
cat /etc/os-release
|
||||
dpkg-query -W -f='${Package}=${Version}\n' \
|
||||
build-essential libasound2-dev libayatana-appindicator3-dev \
|
||||
libgtk-3-dev librsvg2-dev libssl-dev libwebkit2gtk-4.1-dev \
|
||||
libxdo-dev patchelf pkg-config
|
||||
gcc -dumpfullversion -dumpversion
|
||||
gcc -dumpmachine
|
||||
ld --version
|
||||
} ;;
|
||||
windows)
|
||||
{
|
||||
cmd.exe //c ver
|
||||
powershell.exe -NoProfile -Command '$vswhere = "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe"; & $vswhere -latest -products * -property installationVersion; Get-ChildItem "${env:ProgramFiles}\Microsoft Visual Studio\2022" -Directory -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Name; Get-ChildItem "${env:ProgramFiles(x86)}\Windows Kits\10\Include" -Directory | Select-Object -ExpandProperty Name; Get-ChildItem "${env:ProgramFiles}\Microsoft Visual Studio\2022\*\VC\Tools\MSVC" -Directory | Select-Object -ExpandProperty Name'
|
||||
cmake --version
|
||||
} ;;
|
||||
*)
|
||||
echo "unsupported native toolchain platform: $platform" >&2
|
||||
exit 1 ;;
|
||||
esac | tr -d '\r' | python3 -c 'import hashlib, sys; print(hashlib.sha256(sys.stdin.buffer.read()).hexdigest())'
|
||||
@@ -0,0 +1,85 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Compute an exact, version-agnostic Cargo release cache key."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import pathlib
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
ROOT = pathlib.Path(__file__).resolve().parents[1]
|
||||
DESKTOP_MANIFEST = pathlib.Path("desktop/src-tauri/Cargo.toml")
|
||||
DESKTOP_LOCK = pathlib.Path("desktop/src-tauri/Cargo.lock")
|
||||
|
||||
|
||||
def normalized(path: pathlib.Path, data: bytes) -> bytes:
|
||||
text = data.decode()
|
||||
if path == DESKTOP_MANIFEST:
|
||||
text, count = re.subn(
|
||||
r'(?ms)(^\[package\].*?^version\s*=\s*)"[^"]+"',
|
||||
r'\1"<desktop-version>"',
|
||||
text,
|
||||
count=1,
|
||||
)
|
||||
if count != 1:
|
||||
raise ValueError(f"could not normalize package version in {path}")
|
||||
elif path == DESKTOP_LOCK:
|
||||
text, count = re.subn(
|
||||
r'(?ms)(^name = "buzz-desktop"\nversion = )"[^"]+"',
|
||||
r'\1"<desktop-version>"',
|
||||
text,
|
||||
count=1,
|
||||
)
|
||||
if count != 1:
|
||||
raise ValueError(f"could not normalize package version in {path}")
|
||||
return text.encode()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--platform", required=True)
|
||||
parser.add_argument("--target", required=True)
|
||||
parser.add_argument("--features", default="default")
|
||||
parser.add_argument("--native-inputs", required=True)
|
||||
args = parser.parse_args()
|
||||
|
||||
manifest_output = subprocess.check_output(
|
||||
["git", "ls-files", "*Cargo.toml"], cwd=ROOT, text=True
|
||||
)
|
||||
paths = [ROOT / path for path in manifest_output.splitlines()]
|
||||
paths += [ROOT / "Cargo.lock", ROOT / DESKTOP_LOCK, ROOT / "rust-toolchain.toml"]
|
||||
cargo_config = ROOT / ".cargo/config.toml"
|
||||
if cargo_config.exists():
|
||||
paths.append(cargo_config)
|
||||
|
||||
digest = hashlib.sha256()
|
||||
descriptors = {
|
||||
"schema": "desktop-rust-release-v1",
|
||||
"platform": args.platform,
|
||||
"target": args.target,
|
||||
"profile": "release",
|
||||
"features": args.features,
|
||||
"native-inputs": args.native_inputs,
|
||||
"rustc": subprocess.check_output(["rustc", "-Vv"], text=True).strip(),
|
||||
}
|
||||
for name, value in sorted(descriptors.items()):
|
||||
digest.update(f"{name}\0{value}\0".encode())
|
||||
|
||||
for absolute in sorted(set(paths)):
|
||||
relative = absolute.relative_to(ROOT)
|
||||
digest.update(str(relative).encode() + b"\0")
|
||||
digest.update(normalized(relative, absolute.read_bytes()) + b"\0")
|
||||
|
||||
print(f"desktop-rust-release-v1-{args.platform}-{args.target}-{digest.hexdigest()}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
raise SystemExit(main())
|
||||
except (OSError, ValueError, subprocess.CalledProcessError) as error:
|
||||
print(f"error: {error}", file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
@@ -0,0 +1,287 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate and validate immutable desktop release candidates."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(os.environ.get("DESKTOP_RELEASE_ROOT", Path(__file__).resolve().parent.parent))
|
||||
CHANGELOG = ROOT / "CHANGELOG.md"
|
||||
METADATA = ROOT / ".release" / "desktop-candidate.json"
|
||||
SEMVER = re.compile(r"^[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?$")
|
||||
STABLE_TAG = re.compile(r"desktop-v([0-9]+)\.([0-9]+)\.([0-9]+)$")
|
||||
DESKTOP_PATHS = (
|
||||
"desktop/",
|
||||
"crates/buzz-core/",
|
||||
"crates/buzz-persona/",
|
||||
"crates/buzz-sdk/",
|
||||
"crates/buzz-agent/",
|
||||
"crates/buzz-media/",
|
||||
)
|
||||
CANDIDATE_FILES = {
|
||||
".release/desktop-candidate.json",
|
||||
"CHANGELOG.md",
|
||||
"desktop/package.json",
|
||||
"desktop/src-tauri/tauri.conf.json",
|
||||
"desktop/src-tauri/Cargo.toml",
|
||||
"desktop/src-tauri/Cargo.lock",
|
||||
"pnpm-lock.yaml",
|
||||
}
|
||||
REQUIRED_CANDIDATE_FILES = {
|
||||
".release/desktop-candidate.json",
|
||||
"CHANGELOG.md",
|
||||
"desktop/package.json",
|
||||
"desktop/src-tauri/tauri.conf.json",
|
||||
"desktop/src-tauri/Cargo.toml",
|
||||
}
|
||||
|
||||
|
||||
def git(*args: str) -> str:
|
||||
return subprocess.check_output(["git", *args], cwd=ROOT, text=True).strip()
|
||||
|
||||
|
||||
def commit_list(range_spec: str, paths: tuple[str, ...] | None = None) -> list[dict[str, str]]:
|
||||
args = ["log", range_spec, "--no-merges", "--format=%H%x00%s"]
|
||||
if paths:
|
||||
args += ["--", *paths]
|
||||
out = git(*args)
|
||||
if not out:
|
||||
return []
|
||||
return [dict(zip(("sha", "subject"), line.split("\0", 1))) for line in out.splitlines()]
|
||||
|
||||
|
||||
def gh_json(endpoint: str) -> object:
|
||||
try:
|
||||
return json.loads(subprocess.check_output(
|
||||
["gh", "api", endpoint], cwd=ROOT, text=True
|
||||
))
|
||||
except (subprocess.CalledProcessError, json.JSONDecodeError) as error:
|
||||
raise SystemExit(f"cannot verify prior desktop release via GitHub: {error}") from error
|
||||
|
||||
|
||||
def stable_tags() -> list[tuple[tuple[int, int, int], str, str]]:
|
||||
tags: list[tuple[tuple[int, int, int], str, str]] = []
|
||||
aliases: dict[tuple[int, int, int], list[tuple[str, str]]] = {}
|
||||
for tag in git("tag", "--list", "desktop-v*").splitlines():
|
||||
match = STABLE_TAG.fullmatch(tag)
|
||||
if not match:
|
||||
continue
|
||||
version = tuple(map(int, match.groups()))
|
||||
sha = git("rev-list", "-n", "1", tag)
|
||||
aliases.setdefault(version, []).append((tag, sha))
|
||||
for version, refs in aliases.items():
|
||||
if len(refs) != 1:
|
||||
detail = ", ".join(f"{tag}@{sha}" for tag, sha in refs)
|
||||
raise SystemExit(f"ambiguous desktop release version {version}: {detail}")
|
||||
tag, sha = refs[0]
|
||||
tags.append((version, tag, sha))
|
||||
return tags
|
||||
|
||||
|
||||
def previous_release(
|
||||
version: str, repo: str, *, allow_target_sha: str | None = None
|
||||
) -> dict[str, str] | None:
|
||||
target = tuple(map(int, version.split("-", 1)[0].split(".")))
|
||||
target_tag = f"desktop-v{version}"
|
||||
target_refs = git("tag", "--list", target_tag).splitlines()
|
||||
target_ref = (
|
||||
(target_tag, git("rev-list", "-n", "1", target_tag))
|
||||
if target_refs
|
||||
else None
|
||||
)
|
||||
target_collision = target_ref is not None and (
|
||||
allow_target_sha is None or target_ref[1] != allow_target_sha
|
||||
)
|
||||
tags = stable_tags()
|
||||
newer = [item for item in tags if item[0] > target]
|
||||
equal = [item for item in tags if item[0] == target]
|
||||
allowed_stable_retry = (
|
||||
"-" not in version
|
||||
and len(equal) == 1
|
||||
and allow_target_sha is not None
|
||||
and equal[0][1] == target_tag
|
||||
and equal[0][2] == allow_target_sha
|
||||
)
|
||||
if target_collision or newer or (equal and not allowed_stable_retry):
|
||||
blocked = [item[1] for item in newer + equal]
|
||||
if target_collision and target_tag not in blocked:
|
||||
blocked.append(target_tag)
|
||||
detail = ", ".join(blocked)
|
||||
raise SystemExit(f"desktop release version must increase beyond existing tags: {detail}")
|
||||
eligible = [item for item in tags if item[0] < target]
|
||||
if not eligible:
|
||||
return None
|
||||
prior_version, tag, candidate_sha = max(eligible)
|
||||
try:
|
||||
metadata = json.loads(git("show", f"{tag}:.release/desktop-candidate.json"))
|
||||
except (subprocess.CalledProcessError, json.JSONDecodeError) as error:
|
||||
raise SystemExit(f"prior release {tag} has invalid candidate metadata") from error
|
||||
expected = {
|
||||
"version": ".".join(map(str, prior_version)),
|
||||
"tag": tag,
|
||||
}
|
||||
if any(metadata.get(key) != value for key, value in expected.items()):
|
||||
raise SystemExit(f"prior release {tag} metadata does not match its tag")
|
||||
base_sha = metadata.get("base_sha")
|
||||
if not isinstance(base_sha, str) or not re.fullmatch(r"[0-9a-f]{40}", base_sha):
|
||||
raise SystemExit(f"prior release {tag} has invalid base_sha")
|
||||
pulls = gh_json(f"repos/{repo}/commits/{candidate_sha}/pulls")
|
||||
matches = [pr for pr in pulls if pr.get("merged_at") and (
|
||||
pr.get("head", {}).get("sha") == candidate_sha
|
||||
or pr.get("merge_commit_sha") == candidate_sha
|
||||
)]
|
||||
if len(matches) != 1 or not matches[0].get("merge_commit_sha"):
|
||||
raise SystemExit(f"prior release {tag} does not identify exactly one merged release PR")
|
||||
return {
|
||||
"tag": tag,
|
||||
"candidate_sha": candidate_sha,
|
||||
"base_sha": base_sha,
|
||||
"merge_sha": matches[0]["merge_commit_sha"],
|
||||
}
|
||||
|
||||
|
||||
def bullet(commit: dict[str, str], repo: str) -> str:
|
||||
sha, subject = commit["sha"], commit["subject"]
|
||||
short = sha[:12]
|
||||
pr_match = re.search(r" \(#([0-9]+)\)$", subject)
|
||||
if pr_match:
|
||||
pr = pr_match.group(1)
|
||||
subject = subject[: pr_match.start()]
|
||||
return f"- {subject} ([#{pr}](https://github.com/{repo}/pull/{pr})) ([`{sha}`](https://github.com/{repo}/commit/{sha}))"
|
||||
return f"- {subject} ([`{sha}`](https://github.com/{repo}/commit/{sha}))"
|
||||
|
||||
|
||||
def expected(base_sha: str, previous_base: str, previous_merge: str) -> tuple[list[dict[str, str]], list[dict[str, str]]]:
|
||||
# Immutable candidate tags may live on side history after squash merge. The
|
||||
# prior candidate metadata is the ledger boundary; exclude only its known
|
||||
# squash commit so unrelated commits around that merge remain accounted for.
|
||||
range_spec = f"{previous_base}..{base_sha}" if previous_base else base_sha
|
||||
all_commits = [c for c in commit_list(range_spec) if c["sha"] != previous_merge]
|
||||
relevant_shas = {c["sha"] for c in commit_list(range_spec, DESKTOP_PATHS)} - {previous_merge}
|
||||
relevant = [c for c in all_commits if c["sha"] in relevant_shas]
|
||||
other = [c for c in all_commits if c["sha"] not in relevant_shas]
|
||||
return relevant, other
|
||||
|
||||
|
||||
def render(version: str, base_sha: str, previous: dict[str, str] | None, repo: str) -> tuple[str, list[str]]:
|
||||
relevant, other = expected(
|
||||
base_sha, previous["base_sha"] if previous else "", previous["merge_sha"] if previous else ""
|
||||
)
|
||||
lines = [f"## v{version}", "", "### Desktop and shared changes", ""]
|
||||
lines += [bullet(c, repo) for c in relevant] or ["- None"]
|
||||
lines += ["", "### Other repository changes", ""]
|
||||
lines += [bullet(c, repo) for c in other] or ["- None"]
|
||||
compare_start = previous["tag"] if previous else git("rev-list", "--max-parents=0", base_sha).splitlines()[0]
|
||||
lines += ["", f"[Compare {compare_start}...desktop-v{version}](https://github.com/{repo}/compare/{compare_start}...desktop-v{version})"]
|
||||
return "\n".join(lines) + "\n", [c["sha"] for c in relevant + other]
|
||||
|
||||
|
||||
def generate(args: argparse.Namespace) -> None:
|
||||
if not SEMVER.fullmatch(args.version):
|
||||
raise SystemExit(f"invalid semver: {args.version}")
|
||||
base_sha = git("rev-parse", args.base)
|
||||
repo = args.repo or re.sub(r".*github\.com[:/]", "", git("remote", "get-url", "origin")).removesuffix(".git")
|
||||
previous = previous_release(args.version, repo)
|
||||
block, commits = render(args.version, base_sha, previous, repo)
|
||||
old = CHANGELOG.read_text() if CHANGELOG.exists() else "# Changelog\n"
|
||||
if not old.startswith("# Changelog"):
|
||||
raise SystemExit("CHANGELOG.md must begin with '# Changelog'")
|
||||
remainder = old.split("\n", 1)[1].lstrip("\n") if "\n" in old else ""
|
||||
CHANGELOG.write_text(f"# Changelog\n\n{block}\n{remainder}")
|
||||
METADATA.parent.mkdir(parents=True, exist_ok=True)
|
||||
METADATA.write_text(json.dumps({
|
||||
"schema": 2,
|
||||
"version": args.version,
|
||||
"base_sha": base_sha,
|
||||
"previous_tag": previous["tag"] if previous else None,
|
||||
"previous_base_sha": previous["base_sha"] if previous else None,
|
||||
"previous_merge_sha": previous["merge_sha"] if previous else None,
|
||||
"tag": f"desktop-v{args.version}",
|
||||
"commit_count": len(commits),
|
||||
}, indent=2) + "\n")
|
||||
|
||||
|
||||
def validate(args: argparse.Namespace) -> None:
|
||||
data = json.loads(METADATA.read_text())
|
||||
version = args.version or data["version"]
|
||||
if data != {**data, "version": version}:
|
||||
raise SystemExit("candidate version does not match metadata")
|
||||
if data["tag"] != f"desktop-v{version}":
|
||||
raise SystemExit("candidate tag does not match version")
|
||||
candidate = git("rev-parse", args.candidate)
|
||||
parents = git("show", "-s", "--format=%P", candidate).split()
|
||||
if len(parents) != 1 or parents[0] != data["base_sha"]:
|
||||
raise SystemExit("candidate must be one commit directly above recorded base_sha")
|
||||
changed = set(git("diff-tree", "--no-commit-id", "--name-only", "-r", candidate).splitlines())
|
||||
unexpected = changed - CANDIDATE_FILES
|
||||
missing = REQUIRED_CANDIDATE_FILES - changed
|
||||
if unexpected or missing:
|
||||
detail = []
|
||||
if unexpected:
|
||||
detail.append(f"unexpected files: {', '.join(sorted(unexpected))}")
|
||||
if missing:
|
||||
detail.append(f"missing required files: {', '.join(sorted(missing))}")
|
||||
raise SystemExit("candidate is not version-only (" + "; ".join(detail) + ")")
|
||||
repo = args.repo or "block/buzz"
|
||||
previous = previous_release(version, repo, allow_target_sha=candidate)
|
||||
recorded_previous = {
|
||||
"tag": data.get("previous_tag"),
|
||||
"base_sha": data.get("previous_base_sha"),
|
||||
"merge_sha": data.get("previous_merge_sha"),
|
||||
} if data.get("previous_tag") else None
|
||||
expected_previous = {key: previous[key] for key in ("tag", "base_sha", "merge_sha")} if previous else None
|
||||
if recorded_previous != expected_previous:
|
||||
raise SystemExit("recorded previous release ledger does not match immutable prior release")
|
||||
expected_block, shas = render(version, data["base_sha"], previous, repo)
|
||||
text = CHANGELOG.read_text()
|
||||
blocks = re.findall(rf"(?ms)^## v{re.escape(version)}\n.*?(?=^## v|\Z)", text)
|
||||
if len(blocks) != 1:
|
||||
raise SystemExit(f"expected exactly one changelog block for v{version}")
|
||||
if blocks[0].rstrip() != expected_block.rstrip():
|
||||
raise SystemExit("changelog block is not deterministic for recorded candidate base")
|
||||
found = re.findall(r"\[`([0-9a-f]{40})`\]", blocks[0])
|
||||
if len(found) != len(set(found)) or set(found) != set(shas) or len(found) != data["commit_count"]:
|
||||
raise SystemExit("changelog does not account for every expected non-merge commit exactly once")
|
||||
manifests = {
|
||||
ROOT / "desktop/package.json": json.loads((ROOT / "desktop/package.json").read_text())["version"],
|
||||
ROOT / "desktop/src-tauri/tauri.conf.json": json.loads((ROOT / "desktop/src-tauri/tauri.conf.json").read_text())["version"],
|
||||
}
|
||||
cargo = re.search(r'(?m)^version = "([^"]+)"', (ROOT / "desktop/src-tauri/Cargo.toml").read_text())
|
||||
manifests[ROOT / "desktop/src-tauri/Cargo.toml"] = cargo.group(1) if cargo else ""
|
||||
bad = [str(path.relative_to(ROOT)) for path, value in manifests.items() if value != version]
|
||||
if bad:
|
||||
raise SystemExit(f"version mismatch in: {', '.join(bad)}")
|
||||
author = git("show", "-s", "--format=%an <%ae>", candidate)
|
||||
body = git("show", "-s", "--format=%B", candidate)
|
||||
if author != "Wes <wesbillman@users.noreply.github.com>":
|
||||
raise SystemExit(f"unexpected candidate author: {author}")
|
||||
if "Signed-off-by: Wes <wesbillman@users.noreply.github.com>" not in body:
|
||||
raise SystemExit("candidate is missing Wes Signed-off-by trailer")
|
||||
if not re.search(r"(?m)^Co-authored-by: .+ <.+>$", body):
|
||||
raise SystemExit("candidate is missing automation Co-authored-by trailer")
|
||||
print(f"validated immutable desktop candidate {candidate} for desktop-v{version}")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser()
|
||||
sub = parser.add_subparsers(dest="command", required=True)
|
||||
gen = sub.add_parser("generate")
|
||||
gen.add_argument("version")
|
||||
gen.add_argument("--base", required=True)
|
||||
gen.add_argument("--repo")
|
||||
val = sub.add_parser("validate")
|
||||
val.add_argument("--candidate", default="HEAD")
|
||||
val.add_argument("--version")
|
||||
val.add_argument("--repo")
|
||||
args = parser.parse_args()
|
||||
generate(args) if args.command == "generate" else validate(args)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,62 @@
|
||||
#!/usr/bin/env bash
|
||||
# =============================================================================
|
||||
# dev-reset.sh — Tear down everything and recreate a clean environment
|
||||
# =============================================================================
|
||||
# Usage: ./scripts/dev-reset.sh
|
||||
#
|
||||
# Stops all services, removes development desktop state and ALL local service
|
||||
# volumes (data is lost!), brings everything back up fresh, and runs migrations.
|
||||
# Installed Buzz state is preserved.
|
||||
# =============================================================================
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
||||
|
||||
# Colors
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m'
|
||||
|
||||
log() { echo -e "${BLUE}[dev-reset]${NC} $*"; }
|
||||
success(){ echo -e "${GREEN}[dev-reset]${NC} $*"; }
|
||||
warn() { echo -e "${YELLOW}[dev-reset]${NC} $*"; }
|
||||
error() { echo -e "${RED}[dev-reset]${NC} $*" >&2; }
|
||||
|
||||
cd "${REPO_ROOT}"
|
||||
|
||||
# ---- Confirm ----------------------------------------------------------------
|
||||
|
||||
if [[ "${1:-}" != "--yes" ]]; then
|
||||
echo -e "${YELLOW}WARNING: This will DELETE all development data (desktop state, postgres, minio volumes).${NC}"
|
||||
echo -e " Installed Buzz app state and its production keyring are preserved."
|
||||
echo -e " Redis data is ephemeral and always wiped on restart."
|
||||
echo ""
|
||||
read -r -p "Are you sure? [y/N] " confirm
|
||||
case "${confirm}" in
|
||||
[yY][eE][sS]|[yY]) ;;
|
||||
*)
|
||||
log "Aborted."
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
|
||||
# ---- Desktop development state ---------------------------------------------
|
||||
|
||||
log "Removing desktop development state..."
|
||||
"${SCRIPT_DIR}/reset-desktop-dev-state.sh"
|
||||
success "Desktop development state removed (installed Buzz state preserved)"
|
||||
|
||||
# ---- Tear down --------------------------------------------------------------
|
||||
|
||||
log "Stopping and removing containers + volumes..."
|
||||
docker compose down -v --remove-orphans 2>/dev/null || true
|
||||
success "Containers and volumes removed"
|
||||
|
||||
# ---- Bring back up ----------------------------------------------------------
|
||||
|
||||
log "Recreating environment..."
|
||||
exec "${SCRIPT_DIR}/dev-setup.sh"
|
||||
@@ -0,0 +1,204 @@
|
||||
#!/usr/bin/env bash
|
||||
# =============================================================================
|
||||
# dev-setup.sh — One-shot local dev environment setup
|
||||
# =============================================================================
|
||||
# Usage: ./scripts/dev-setup.sh
|
||||
#
|
||||
# Starts Docker services, waits for healthy, runs migrations, installs desktop
|
||||
# deps, and prints next steps.
|
||||
# =============================================================================
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
||||
|
||||
# Colors
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
log() { echo -e "${BLUE}[dev-setup]${NC} $*"; }
|
||||
success() { echo -e "${GREEN}[dev-setup]${NC} $*"; }
|
||||
warn() { echo -e "${YELLOW}[dev-setup]${NC} $*"; }
|
||||
error() { echo -e "${RED}[dev-setup]${NC} $*" >&2; }
|
||||
|
||||
# ---- Preflight checks -------------------------------------------------------
|
||||
|
||||
if ! command -v docker &>/dev/null; then
|
||||
error "Docker not found. Install Docker Desktop: https://www.docker.com/products/docker-desktop/"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! docker info &>/dev/null; then
|
||||
error "Docker daemon is not running. Start Docker Desktop and try again."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cd "${REPO_ROOT}"
|
||||
|
||||
# ---- Load environment -------------------------------------------------------
|
||||
|
||||
load_env() {
|
||||
if [[ -f ".env" ]]; then
|
||||
log "Loading .env..."
|
||||
set -o allexport
|
||||
# shellcheck disable=SC1091
|
||||
source .env
|
||||
set +o allexport
|
||||
fi
|
||||
|
||||
# Smooth the local rename path for developers with a pre-Buzz .env copied
|
||||
# from .env.example. Only rewrite the old default values; custom values stay
|
||||
# untouched.
|
||||
if [[ "${DATABASE_URL:-}" == "postgres://sprout:sprout_dev@localhost:5432/sprout" ]]; then
|
||||
warn "Migrating legacy default DATABASE_URL from sprout to buzz for this setup run"
|
||||
DATABASE_URL="postgres://buzz:buzz_dev@localhost:5432/buzz"
|
||||
fi
|
||||
if [[ "${PGUSER:-}" == "sprout" ]]; then PGUSER="buzz"; fi
|
||||
if [[ "${PGPASSWORD:-}" == "sprout_dev" ]]; then PGPASSWORD="buzz_dev"; fi
|
||||
if [[ "${PGDATABASE:-}" == "sprout" ]]; then PGDATABASE="buzz"; fi
|
||||
|
||||
export DATABASE_URL="${DATABASE_URL:-postgres://buzz:buzz_dev@localhost:5432/buzz}"
|
||||
export PGHOST="${PGHOST:-localhost}"
|
||||
export PGPORT="${PGPORT:-5432}"
|
||||
export PGUSER="${PGUSER:-buzz}"
|
||||
export PGPASSWORD="${PGPASSWORD:-buzz_dev}"
|
||||
export PGDATABASE="${PGDATABASE:-buzz}"
|
||||
export REDIS_URL="${REDIS_URL:-redis://localhost:6379}"
|
||||
}
|
||||
|
||||
cleanup_legacy_sprout_containers() {
|
||||
local legacy_containers
|
||||
legacy_containers=$(docker ps -a --format '{{.Names}}' | grep -E '^sprout-(postgres|redis|adminer|keycloak|minio|minio-init|prometheus)$' || true)
|
||||
if [[ -z "${legacy_containers}" ]]; then
|
||||
return
|
||||
fi
|
||||
|
||||
warn "Stopping/removing legacy sprout-* dev containers so buzz-* containers can bind the standard ports"
|
||||
echo "${legacy_containers}" | xargs docker stop >/dev/null 2>&1 || true
|
||||
echo "${legacy_containers}" | xargs docker rm >/dev/null 2>&1 || true
|
||||
success "Legacy sprout-* containers removed (volumes preserved)"
|
||||
}
|
||||
|
||||
fail_if_local_redis_blocks_compose() {
|
||||
if ! command -v lsof >/dev/null 2>&1; then
|
||||
return
|
||||
fi
|
||||
if docker ps --format '{{.Names}}' | grep -qx 'buzz-redis'; then
|
||||
return
|
||||
fi
|
||||
local redis_pids
|
||||
redis_pids=$(lsof -nP -iTCP:6379 -sTCP:LISTEN 2>/dev/null | awk 'NR > 1 && $1 == "redis-ser" {print $2}' | sort -u | tr '
|
||||
' ' ' || true)
|
||||
if [[ -n "${redis_pids}" ]]; then
|
||||
error "Local Redis is already listening on port 6379 (pid(s): ${redis_pids}). Stop it before running setup: brew services stop redis"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
postgres_accepting_connections() {
|
||||
docker exec buzz-postgres \
|
||||
pg_isready -h localhost -p 5432 -U "${PGUSER}" -d "${PGDATABASE}" \
|
||||
>/dev/null 2>&1
|
||||
}
|
||||
|
||||
load_env
|
||||
cleanup_legacy_sprout_containers
|
||||
fail_if_local_redis_blocks_compose
|
||||
|
||||
# ---- Start services ---------------------------------------------------------
|
||||
|
||||
log "Starting services and waiting for health..."
|
||||
"${REPO_ROOT}/bin/just" _ensure-services
|
||||
|
||||
# ---- Run migrations ---------------------------------------------------------
|
||||
|
||||
log "Running database migrations..."
|
||||
attempts=0
|
||||
max_attempts=10
|
||||
until postgres_accepting_connections; do
|
||||
attempts=$((attempts + 1))
|
||||
if [[ ${attempts} -ge ${max_attempts} ]]; then
|
||||
error "Postgres did not accept connections after ${max_attempts} attempts"
|
||||
exit 1
|
||||
fi
|
||||
log "Postgres not ready for connections yet, retrying in 2s... (${attempts}/${max_attempts})"
|
||||
sleep 2
|
||||
done
|
||||
|
||||
"${REPO_ROOT}/bin/cargo" run -p buzz-admin -- migrate
|
||||
"${REPO_ROOT}/scripts/seed-local-community.sh"
|
||||
success "Database migrations complete"
|
||||
|
||||
# ---- Install desktop dependencies -------------------------------------------
|
||||
|
||||
DESKTOP_DIR="${REPO_ROOT}/desktop"
|
||||
|
||||
if [[ -d "${DESKTOP_DIR}" ]]; then
|
||||
if command -v pnpm &>/dev/null; then
|
||||
log "Installing desktop dependencies (pnpm install)..."
|
||||
(cd "${DESKTOP_DIR}" && pnpm install)
|
||||
success "Desktop dependencies installed"
|
||||
else
|
||||
warn "pnpm not found — skipping desktop dependency install."
|
||||
warn "Run '. ./bin/activate-hermit' to get pnpm, then 'just desktop-install'."
|
||||
fi
|
||||
else
|
||||
warn "Desktop directory not found at ${DESKTOP_DIR} — skipping."
|
||||
fi
|
||||
|
||||
# ---- Install web dependencies -----------------------------------------------
|
||||
|
||||
WEB_DIR="${REPO_ROOT}/web"
|
||||
|
||||
if [[ -d "${WEB_DIR}" ]]; then
|
||||
if command -v pnpm &>/dev/null; then
|
||||
log "Installing web dependencies (pnpm install)..."
|
||||
(cd "${WEB_DIR}" && pnpm install)
|
||||
success "Web dependencies installed"
|
||||
else
|
||||
warn "pnpm not found — skipping web dependency install."
|
||||
warn "Run '. ./bin/activate-hermit' to get pnpm, then 'just desktop-install'."
|
||||
fi
|
||||
else
|
||||
warn "Web directory not found at ${WEB_DIR} — skipping."
|
||||
fi
|
||||
|
||||
# ---- Install git hooks ------------------------------------------------------
|
||||
|
||||
log "Installing git hooks..."
|
||||
# Install into the shared .git/hooks directory using --path-format=absolute so the
|
||||
# stored hooksPath is always an absolute path. Without it, --git-common-dir returns
|
||||
# ".git" from the main checkout; a relative hooksPath would silently break
|
||||
# linked-worktree dispatch (same failure mode as the old worktree-relative .hooks).
|
||||
HOOKS_DIR="$(git -C "${REPO_ROOT}" rev-parse --path-format=absolute --git-common-dir)/hooks"
|
||||
git -C "${REPO_ROOT}" config --local core.hooksPath "$HOOKS_DIR"
|
||||
lefthook install --force
|
||||
success "Git hooks installed"
|
||||
|
||||
# ---- Print connection info --------------------------------------------------
|
||||
|
||||
echo ""
|
||||
echo -e "${GREEN}=======================================================${NC}"
|
||||
echo -e "${GREEN} Buzz dev environment is ready!${NC}"
|
||||
echo -e "${GREEN}=======================================================${NC}"
|
||||
echo ""
|
||||
echo -e " ${BLUE}Postgres${NC} ${DATABASE_URL}"
|
||||
echo -e " ${BLUE}Redis${NC} ${REDIS_URL}"
|
||||
echo -e " ${BLUE}Adminer${NC} http://localhost:8082 (DB browser)"
|
||||
echo -e " ${BLUE}Keycloak${NC} http://localhost:8180 (admin / admin — local OAuth testing)"
|
||||
echo ""
|
||||
echo -e " ${YELLOW}Next steps:${NC}"
|
||||
echo -e " just relay # start the relay (terminal 1)"
|
||||
echo -e " just dev # start the desktop app (terminal 2)"
|
||||
echo ""
|
||||
echo -e " ${YELLOW}Useful commands:${NC}"
|
||||
echo -e " docker compose ps # check service status"
|
||||
echo -e " docker compose logs -f # tail all logs"
|
||||
echo -e " docker compose down # stop services (keep data)"
|
||||
echo -e " ./scripts/dev-reset.sh # wipe and start fresh"
|
||||
echo ""
|
||||
|
||||
exit 0
|
||||
@@ -0,0 +1,952 @@
|
||||
#!/usr/bin/env bash
|
||||
# =============================================================================
|
||||
# e2e-git-perms.sh — End-to-end test for git transport, permissions, and signing
|
||||
# =============================================================================
|
||||
# Two bots collaborate on a simple web page via the Buzz relay's git server.
|
||||
#
|
||||
# Prerequisites:
|
||||
# - Docker services running (postgres, redis, minio)
|
||||
# - Relay built: cargo build --release --bin buzz-relay
|
||||
# - Credential helper built: cargo build --release --bin git-credential-nostr
|
||||
# - Signing program built: cargo build --release --bin git-sign-nostr
|
||||
# - Python 3 with websocket-client: pip install websocket-client
|
||||
#
|
||||
# What it tests:
|
||||
# Phase 1 — Transport + RBAC:
|
||||
# 1. Owner creates a repo (kind:30617) and a channel
|
||||
# 2. Owner adds two bots to the channel
|
||||
# 3. Bot1 clones, creates index.html, pushes (should succeed)
|
||||
# 4. Bot2 clones, modifies index.html, pushes (should succeed)
|
||||
# 5. Guest tries to push (should be denied)
|
||||
# Phase 2 — Commit Signing (NIP-GS):
|
||||
# 6. Unsigned commit pushes fine (advisory model)
|
||||
# 7. Signed commit via git-sign-nostr + verify-commit
|
||||
# 8. Signed commit with owner attestation (oa field)
|
||||
# Phase 3 — Auth Bypass + HMAC:
|
||||
# 9. No auth header → 401
|
||||
# 10. Malformed auth header → 401
|
||||
# 11. Garbage Nostr token → 401
|
||||
# 12. Forged HMAC on policy endpoint → rejected
|
||||
# Phase 4 — Hook Integrity:
|
||||
# 13. Hook is regular file + executable
|
||||
# 14. Symlink hook → push denied
|
||||
# 15. Missing hook → push denied
|
||||
# 16. Client core.hooksPath override cannot bypass server hook
|
||||
# =============================================================================
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
||||
cd "${REPO_ROOT}"
|
||||
|
||||
# ── Constants ─────────────────────────────────────────────────────────────────
|
||||
|
||||
RELAY_HOST="127.0.0.1"
|
||||
RELAY_PORT="3000"
|
||||
RELAY_HTTP="http://${RELAY_HOST}:${RELAY_PORT}"
|
||||
RELAY_WS="ws://${RELAY_HOST}:${RELAY_PORT}"
|
||||
REPO_NAME="e2e-webpage"
|
||||
HMAC_SECRET="e2e-test-secret-that-is-long-enough-for-validation-purposes"
|
||||
RELAY_STARTUP_TIMEOUT=15
|
||||
CURL_TIMEOUT=10
|
||||
|
||||
# NIP-29 event kinds
|
||||
KIND_CREATE_GROUP=9007
|
||||
KIND_PUT_USER=9000
|
||||
KIND_CREATE_REPO=30617
|
||||
|
||||
# ── Output helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m'
|
||||
|
||||
log() { printf "${BLUE}[e2e-git]${NC} %s\n" "$*"; }
|
||||
success() { printf "${GREEN}[e2e-git]${NC} ✓ %s\n" "$*"; }
|
||||
fail() { printf "${RED}[e2e-git]${NC} ✗ %s\n" "$*" >&2; cleanup; exit 1; }
|
||||
warn() { printf "${YELLOW}[e2e-git]${NC} %s\n" "$*"; }
|
||||
|
||||
# ── Cleanup ───────────────────────────────────────────────────────────────────
|
||||
|
||||
RELAY_PID=""
|
||||
WORK_DIR=""
|
||||
|
||||
cleanup() {
|
||||
if [[ -n "$RELAY_PID" ]]; then
|
||||
kill "$RELAY_PID" 2>/dev/null || true
|
||||
wait "$RELAY_PID" 2>/dev/null || true
|
||||
fi
|
||||
if [[ -n "$WORK_DIR" ]]; then
|
||||
rm -rf "$WORK_DIR"
|
||||
fi
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
# ── Dependency checks ─────────────────────────────────────────────────────────
|
||||
|
||||
check_deps() {
|
||||
local missing=()
|
||||
|
||||
if [[ ! -x "${REPO_ROOT}/target/release/buzz-relay" ]]; then
|
||||
missing+=("buzz-relay (cargo build --release --bin buzz-relay)")
|
||||
fi
|
||||
if [[ ! -x "${REPO_ROOT}/target/release/git-credential-nostr" ]]; then
|
||||
missing+=("git-credential-nostr (cargo build --release --bin git-credential-nostr)")
|
||||
fi
|
||||
if ! command -v python3 &>/dev/null; then
|
||||
missing+=("python3")
|
||||
fi
|
||||
if ! python3 -c "import websocket" 2>/dev/null; then
|
||||
missing+=("python3 websocket-client (pip install websocket-client)")
|
||||
fi
|
||||
if ! command -v curl &>/dev/null; then
|
||||
missing+=("curl")
|
||||
fi
|
||||
if ! command -v git &>/dev/null; then
|
||||
missing+=("git")
|
||||
fi
|
||||
|
||||
if [[ ${#missing[@]} -gt 0 ]]; then
|
||||
fail "Missing dependencies: ${missing[*]}"
|
||||
fi
|
||||
}
|
||||
|
||||
check_deps
|
||||
|
||||
# ── Keypair generation ────────────────────────────────────────────────────────
|
||||
|
||||
generate_keypair() {
|
||||
openssl rand -hex 32
|
||||
}
|
||||
|
||||
# Derive x-only public key from private key using pure Python secp256k1.
|
||||
# NOTE: This is a TEST-ONLY implementation. The scalar multiplication is
|
||||
# variable-time and uses a deterministic nonce (no aux randomness). This is
|
||||
# acceptable for E2E tests but MUST NOT be used for production signing.
|
||||
derive_pubkey() {
|
||||
local privkey="$1"
|
||||
python3 -c "
|
||||
P = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEFFFFFC2F
|
||||
Gx = 0x79BE667EF9DCBBAC55A06295CE870B07029BFCDB2DCE28D959F2815B16F81798
|
||||
Gy = 0x483ADA7726A3C4655DA4FBFC0E1108A8FD17B448A68554199C47D08FFB10D4B8
|
||||
|
||||
def point_add(p1, p2):
|
||||
if p1 is None: return p2
|
||||
if p2 is None: return p1
|
||||
x1, y1 = p1; x2, y2 = p2
|
||||
if x1 == x2 and y1 != y2: return None
|
||||
if x1 == x2: lam = (3*x1*x1) * pow(2*y1, P-2, P) % P
|
||||
else: lam = (y2-y1) * pow(x2-x1, P-2, P) % P
|
||||
x3 = (lam*lam - x1 - x2) % P
|
||||
y3 = (lam*(x1-x3) - y1) % P
|
||||
return (x3, y3)
|
||||
|
||||
def scalar_mult(k, point):
|
||||
result = None; addend = point
|
||||
while k:
|
||||
if k & 1: result = point_add(result, addend)
|
||||
addend = point_add(addend, addend)
|
||||
k >>= 1
|
||||
return result
|
||||
|
||||
k = int('$privkey', 16)
|
||||
pub = scalar_mult(k, (Gx, Gy))
|
||||
print(format(pub[0], '064x'))
|
||||
"
|
||||
}
|
||||
|
||||
# ── Git clone/push helpers ────────────────────────────────────────────────────
|
||||
|
||||
CRED_HELPER="${REPO_ROOT}/target/release/git-credential-nostr"
|
||||
|
||||
# Clone a repo with nostr credential helper configured.
|
||||
# Usage: git_clone <privkey> <repo_url> <dest_dir>
|
||||
git_clone() {
|
||||
local privkey="$1" repo_url="$2" dest_dir="$3"
|
||||
NOSTR_PRIVATE_KEY="$privkey" GIT_TERMINAL_PROMPT=0 \
|
||||
git clone \
|
||||
-c credential.helper="" \
|
||||
-c credential.useHttpPath=true \
|
||||
-c "credential.${RELAY_HTTP}.helper=${CRED_HELPER}" \
|
||||
-c init.defaultBranch=main \
|
||||
"$repo_url" "$dest_dir" 2>&1
|
||||
}
|
||||
|
||||
# Push from a repo with nostr credential helper configured.
|
||||
# Usage: git_push <privkey> <repo_dir> [push_args...]
|
||||
git_push() {
|
||||
local privkey="$1" repo_dir="$2"
|
||||
shift 2
|
||||
NOSTR_PRIVATE_KEY="$privkey" GIT_TERMINAL_PROMPT=0 \
|
||||
git -C "$repo_dir" \
|
||||
-c credential.helper="" \
|
||||
-c credential.useHttpPath=true \
|
||||
-c "credential.${RELAY_HTTP}.helper=${CRED_HELPER}" \
|
||||
push "$@" 2>&1
|
||||
}
|
||||
|
||||
# ── Nostr event helper ────────────────────────────────────────────────────────
|
||||
|
||||
# Send a signed Nostr event via WebSocket.
|
||||
# Usage: send_event <privkey> <kind> <content> <tags_json>
|
||||
# Returns: "OK:<event_id>" on success, exits non-zero on failure.
|
||||
send_event() {
|
||||
local privkey="$1"
|
||||
local kind="$2"
|
||||
local content="$3"
|
||||
shift 3
|
||||
local tags_json="${*:-}"
|
||||
|
||||
python3 << PYEOF
|
||||
import json, hashlib, time
|
||||
import websocket
|
||||
|
||||
P = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEFFFFFC2F
|
||||
N = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141
|
||||
Gx = 0x79BE667EF9DCBBAC55A06295CE870B07029BFCDB2DCE28D959F2815B16F81798
|
||||
Gy = 0x483ADA7726A3C4655DA4FBFC0E1108A8FD17B448A68554199C47D08FFB10D4B8
|
||||
|
||||
def point_add(p1, p2):
|
||||
if p1 is None: return p2
|
||||
if p2 is None: return p1
|
||||
x1, y1 = p1; x2, y2 = p2
|
||||
if x1 == x2 and y1 != y2: return None
|
||||
if x1 == x2: lam = (3*x1*x1) * pow(2*y1, P-2, P) % P
|
||||
else: lam = (y2-y1) * pow(x2-x1, P-2, P) % P
|
||||
x3 = (lam*lam - x1 - x2) % P
|
||||
y3 = (lam*(x1-x3) - y1) % P
|
||||
return (x3, y3)
|
||||
|
||||
def scalar_mult(k, point):
|
||||
result = None; addend = point
|
||||
while k:
|
||||
if k & 1: result = point_add(result, addend)
|
||||
addend = point_add(addend, addend)
|
||||
k >>= 1
|
||||
return result
|
||||
|
||||
def sign_schnorr(privkey_bytes, msg_bytes):
|
||||
"""BIP-340 Schnorr signing — TEST ONLY (variable-time, deterministic nonce)."""
|
||||
k_int = int.from_bytes(privkey_bytes, 'big')
|
||||
pubpoint = scalar_mult(k_int, (Gx, Gy))
|
||||
pubkey_bytes = pubpoint[0].to_bytes(32, 'big')
|
||||
if pubpoint[1] % 2 != 0:
|
||||
k_int = N - k_int
|
||||
nonce_hash = hashlib.sha256(k_int.to_bytes(32, 'big') + msg_bytes).digest()
|
||||
r_int = int.from_bytes(nonce_hash, 'big') % N
|
||||
if r_int == 0: raise Exception("bad nonce")
|
||||
R = scalar_mult(r_int, (Gx, Gy))
|
||||
if R[1] % 2 != 0:
|
||||
r_int = N - r_int
|
||||
R_bytes = R[0].to_bytes(32, 'big')
|
||||
tag_hash = hashlib.sha256(b'BIP0340/challenge').digest()
|
||||
e_hash = hashlib.sha256(tag_hash + tag_hash + R_bytes + pubkey_bytes + msg_bytes).digest()
|
||||
e_int = int.from_bytes(e_hash, 'big') % N
|
||||
s_int = (r_int + e_int * k_int) % N
|
||||
return R_bytes + s_int.to_bytes(32, 'big')
|
||||
|
||||
privkey = bytes.fromhex("${privkey}")
|
||||
pubpoint = scalar_mult(int.from_bytes(privkey, 'big'), (Gx, Gy))
|
||||
pubkey_hex = format(pubpoint[0], '064x')
|
||||
|
||||
created_at = int(time.time())
|
||||
tags = json.loads('[${tags_json}]') if '''${tags_json}'''.strip() else []
|
||||
content = """${content}"""
|
||||
|
||||
serialized = json.dumps([0, pubkey_hex, created_at, ${kind}, tags, content], separators=(',',':'), ensure_ascii=False)
|
||||
id_bytes = hashlib.sha256(serialized.encode()).digest()
|
||||
event_id = id_bytes.hex()
|
||||
sig = sign_schnorr(privkey, id_bytes)
|
||||
|
||||
event = {
|
||||
"id": event_id,
|
||||
"pubkey": pubkey_hex,
|
||||
"created_at": created_at,
|
||||
"kind": ${kind},
|
||||
"tags": tags,
|
||||
"content": content,
|
||||
"sig": sig.hex()
|
||||
}
|
||||
|
||||
ws = websocket.create_connection("${RELAY_WS}", timeout=5)
|
||||
msg = json.loads(ws.recv())
|
||||
if msg[0] == "AUTH":
|
||||
challenge = msg[1]
|
||||
auth_created = int(time.time())
|
||||
auth_tags = [["relay", "${RELAY_WS}"], ["challenge", challenge]]
|
||||
auth_serial = json.dumps([0, pubkey_hex, auth_created, 22242, auth_tags, ""], separators=(',',':'))
|
||||
auth_id = hashlib.sha256(auth_serial.encode()).digest()
|
||||
auth_sig = sign_schnorr(privkey, auth_id)
|
||||
auth_event = {
|
||||
"id": auth_id.hex(),
|
||||
"pubkey": pubkey_hex,
|
||||
"created_at": auth_created,
|
||||
"kind": 22242,
|
||||
"tags": auth_tags,
|
||||
"content": "",
|
||||
"sig": auth_sig.hex()
|
||||
}
|
||||
ws.send(json.dumps(["AUTH", auth_event]))
|
||||
for _ in range(10):
|
||||
resp = json.loads(ws.recv())
|
||||
if resp[0] == "OK" and resp[1] == auth_id.hex():
|
||||
if not resp[2]:
|
||||
print(f"AUTH failed: {resp}")
|
||||
ws.close()
|
||||
exit(1)
|
||||
break
|
||||
else:
|
||||
print("AUTH: no OK received for our auth event")
|
||||
ws.close()
|
||||
exit(1)
|
||||
|
||||
ws.send(json.dumps(["EVENT", event]))
|
||||
for _ in range(10):
|
||||
resp = json.loads(ws.recv())
|
||||
if resp[0] == "OK" and resp[1] == event_id:
|
||||
if resp[2]:
|
||||
print(f"OK:{event_id}")
|
||||
else:
|
||||
reason = resp[3] if len(resp) > 3 else "unknown"
|
||||
print(f"REJECTED:{reason}")
|
||||
exit(1)
|
||||
break
|
||||
else:
|
||||
print("EVENT: no OK received for our event")
|
||||
exit(1)
|
||||
ws.close()
|
||||
PYEOF
|
||||
}
|
||||
|
||||
# ── Start relay ───────────────────────────────────────────────────────────────
|
||||
|
||||
log "Starting relay..."
|
||||
|
||||
if [[ -f .env ]]; then
|
||||
set -o allexport
|
||||
# shellcheck source=/dev/null
|
||||
source .env
|
||||
set +o allexport
|
||||
fi
|
||||
|
||||
export BUZZ_GIT_REPO_PATH="${REPO_ROOT}/repos"
|
||||
export BUZZ_GIT_HOOK_HMAC_SECRET="${HMAC_SECRET}"
|
||||
export BUZZ_BIND_ADDR="${RELAY_HOST}:${RELAY_PORT}"
|
||||
export RELAY_URL="${RELAY_WS}"
|
||||
export RUST_LOG="buzz_relay=warn"
|
||||
export BUZZ_REQUIRE_AUTH_TOKEN=false
|
||||
|
||||
# Clean repos dir (isolated test state)
|
||||
rm -rf "${REPO_ROOT}/repos"
|
||||
mkdir -p "${REPO_ROOT}/repos"
|
||||
|
||||
./target/release/buzz-relay > /tmp/buzz-relay-e2e.log 2>&1 &
|
||||
RELAY_PID=$!
|
||||
|
||||
# Wait for relay to be ready (poll, not sleep)
|
||||
for i in $(seq 1 "$RELAY_STARTUP_TIMEOUT"); do
|
||||
if curl -sf --max-time 2 "${RELAY_HTTP}/" -H "Accept: application/nostr+json" | grep -q "Buzz"; then
|
||||
break
|
||||
fi
|
||||
if [[ $i -eq "$RELAY_STARTUP_TIMEOUT" ]]; then
|
||||
fail "Relay did not start within ${RELAY_STARTUP_TIMEOUT}s. Check /tmp/buzz-relay-e2e.log"
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
success "Relay started (PID $RELAY_PID) on ${RELAY_HOST}:${RELAY_PORT}"
|
||||
|
||||
# ── Generate identities ──────────────────────────────────────────────────────
|
||||
|
||||
log "Generating keypairs..."
|
||||
|
||||
OWNER_PRIVKEY=$(generate_keypair)
|
||||
OWNER_PUBKEY=$(derive_pubkey "$OWNER_PRIVKEY")
|
||||
BOT1_PRIVKEY=$(generate_keypair)
|
||||
BOT1_PUBKEY=$(derive_pubkey "$BOT1_PRIVKEY")
|
||||
BOT2_PRIVKEY=$(generate_keypair)
|
||||
BOT2_PUBKEY=$(derive_pubkey "$BOT2_PRIVKEY")
|
||||
GUEST_PRIVKEY=$(generate_keypair)
|
||||
GUEST_PUBKEY=$(derive_pubkey "$GUEST_PRIVKEY")
|
||||
|
||||
log " Owner: ${OWNER_PUBKEY:0:16}..."
|
||||
log " Bot1: ${BOT1_PUBKEY:0:16}..."
|
||||
log " Bot2: ${BOT2_PUBKEY:0:16}..."
|
||||
log " Guest: ${GUEST_PUBKEY:0:16}..."
|
||||
|
||||
# ── Work directory ────────────────────────────────────────────────────────────
|
||||
|
||||
WORK_DIR=$(mktemp -d)
|
||||
log "Work dir: $WORK_DIR"
|
||||
|
||||
# ── Phase 1: Transport + RBAC ────────────────────────────────────────────────
|
||||
|
||||
log "Creating channel..."
|
||||
|
||||
CHANNEL_ID=$(python3 -c "import uuid; print(str(uuid.uuid4()))")
|
||||
log " Channel ID: $CHANNEL_ID"
|
||||
|
||||
CHANNEL_RESULT=$(send_event "$OWNER_PRIVKEY" "$KIND_CREATE_GROUP" "" \
|
||||
"[\"h\", \"$CHANNEL_ID\"], [\"name\", \"e2e-git-test\"], [\"channel_type\", \"stream\"], [\"visibility\", \"open\"]")
|
||||
log " Channel create: $CHANNEL_RESULT"
|
||||
|
||||
log "Adding bot1 to channel..."
|
||||
ADD_BOT1=$(send_event "$OWNER_PRIVKEY" "$KIND_PUT_USER" "" \
|
||||
"[\"h\", \"$CHANNEL_ID\"], [\"p\", \"$BOT1_PUBKEY\"]")
|
||||
log " Add bot1: $ADD_BOT1"
|
||||
|
||||
log "Adding bot2 to channel..."
|
||||
ADD_BOT2=$(send_event "$OWNER_PRIVKEY" "$KIND_PUT_USER" "" \
|
||||
"[\"h\", \"$CHANNEL_ID\"], [\"p\", \"$BOT2_PUBKEY\"]")
|
||||
log " Add bot2: $ADD_BOT2"
|
||||
|
||||
log "Creating repo: $REPO_NAME..."
|
||||
CREATE_REPO=$(send_event "$OWNER_PRIVKEY" "$KIND_CREATE_REPO" "" \
|
||||
"[\"d\", \"$REPO_NAME\"], [\"buzz-channel\", \"$CHANNEL_ID\"]")
|
||||
log " Create repo: $CREATE_REPO"
|
||||
|
||||
# Wait for repo creation side effect (bare repo on disk)
|
||||
for i in $(seq 1 10); do
|
||||
if [[ -d "${REPO_ROOT}/repos/${OWNER_PUBKEY}/${REPO_NAME}.git" ]]; then
|
||||
break
|
||||
fi
|
||||
if [[ $i -eq 10 ]]; then
|
||||
fail "Repo not created at repos/${OWNER_PUBKEY}/${REPO_NAME}.git within 10s"
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
success "Bare repo created on disk"
|
||||
|
||||
# Verify hook installed
|
||||
REPO_PATH="${REPO_ROOT}/repos/${OWNER_PUBKEY}/${REPO_NAME}.git"
|
||||
HOOK_PATH="${REPO_PATH}/hooks/pre-receive"
|
||||
|
||||
if [[ -x "$HOOK_PATH" ]]; then
|
||||
success "Pre-receive hook installed and executable"
|
||||
else
|
||||
fail "Pre-receive hook not found or not executable"
|
||||
fi
|
||||
|
||||
# ── Test: Bot1 clones and pushes index.html ───────────────────────────────────
|
||||
|
||||
log "Bot1: cloning repo..."
|
||||
BOT1_DIR="$WORK_DIR/bot1/repo"
|
||||
mkdir -p "$WORK_DIR/bot1"
|
||||
|
||||
CLONE_OUTPUT=$(git_clone "$BOT1_PRIVKEY" "${RELAY_HTTP}/git/${OWNER_PUBKEY}/${REPO_NAME}" "$BOT1_DIR" 2>&1) || true
|
||||
|
||||
# Handle empty repo (git clone warns but exits non-zero for empty repos)
|
||||
if [[ ! -d "$BOT1_DIR/.git" ]]; then
|
||||
mkdir -p "$BOT1_DIR"
|
||||
git -C "$BOT1_DIR" init -b main
|
||||
git -C "$BOT1_DIR" remote add origin "${RELAY_HTTP}/git/${OWNER_PUBKEY}/${REPO_NAME}"
|
||||
fi
|
||||
|
||||
# Create index.html
|
||||
cat > "$BOT1_DIR/index.html" << 'HTML'
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Buzz E2E Test Page</title>
|
||||
<style>
|
||||
body { font-family: system-ui; max-width: 800px; margin: 0 auto; padding: 2rem; }
|
||||
h1 { color: #2d5016; }
|
||||
.contributor { padding: 0.5rem; margin: 0.5rem 0; background: #f0f9e8; border-radius: 4px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>🐝 Buzz Collaborative Page</h1>
|
||||
<p>This page was created by two bots collaborating via Buzz's git server.</p>
|
||||
<div class="contributor">
|
||||
<strong>Bot 1</strong> — Created the initial page structure
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
HTML
|
||||
|
||||
git -C "$BOT1_DIR" add -A
|
||||
git -C "$BOT1_DIR" -c user.name="Bot1" -c user.email="bot1@buzz.test" \
|
||||
-c init.defaultBranch=main commit -m "Initial page structure"
|
||||
|
||||
log "Bot1: pushing..."
|
||||
if git_push "$BOT1_PRIVKEY" "$BOT1_DIR" -u origin main; then
|
||||
success "Bot1 push succeeded (member can push)"
|
||||
else
|
||||
tail -20 /tmp/buzz-relay-e2e.log
|
||||
fail "Bot1 push failed (member should be able to push)"
|
||||
fi
|
||||
|
||||
# ── Test: Bot2 clones and pushes ──────────────────────────────────────────────
|
||||
|
||||
log "Bot2: cloning repo..."
|
||||
BOT2_DIR="$WORK_DIR/bot2"
|
||||
|
||||
git_clone "$BOT2_PRIVKEY" "${RELAY_HTTP}/git/${OWNER_PUBKEY}/${REPO_NAME}" "$BOT2_DIR" \
|
||||
|| fail "Bot2 clone failed"
|
||||
|
||||
# Modify index.html — insert before closing </body> to keep valid HTML
|
||||
sed -i.bak '/<\/body>/i\
|
||||
<div class="contributor">\
|
||||
<strong>Bot 2</strong> — Added this section (pushing as bot role → promoted to member)\
|
||||
</div>\
|
||||
<footer>\
|
||||
<p><em>Built with Buzz sovereign git hosting</em></p>\
|
||||
</footer>' "$BOT2_DIR/index.html"
|
||||
rm -f "$BOT2_DIR/index.html.bak"
|
||||
|
||||
git -C "$BOT2_DIR" add -A
|
||||
git -C "$BOT2_DIR" -c user.name="Bot2" -c user.email="bot2@buzz.test" \
|
||||
commit -m "Add bot2 section and footer"
|
||||
|
||||
log "Bot2: pushing..."
|
||||
if git_push "$BOT2_PRIVKEY" "$BOT2_DIR"; then
|
||||
success "Bot2 push succeeded (bot promoted to member)"
|
||||
else
|
||||
tail -20 /tmp/buzz-relay-e2e.log
|
||||
fail "Bot2 push failed (bot should be promoted to member)"
|
||||
fi
|
||||
|
||||
# ── Test: Non-member read denied (SEC-005) + non-member push denied ──────────
|
||||
|
||||
log "Guest: attempting clone (should be denied — SEC-005 read gate)..."
|
||||
GUEST_DIR="$WORK_DIR/guest"
|
||||
|
||||
if git_clone "$GUEST_PRIVKEY" "${RELAY_HTTP}/git/${OWNER_PUBKEY}/${REPO_NAME}" "$GUEST_DIR"; then
|
||||
fail "Guest clone succeeded (non-member reads should be denied — SEC-005)"
|
||||
fi
|
||||
success "Guest clone denied (not a channel member) — SEC-005 read gate"
|
||||
|
||||
# Members can still read: bot1/bot2 cloned successfully above, and the
|
||||
# owner-verification clone below re-confirms member read access post-gate.
|
||||
|
||||
log "Guest: attempting push (should be denied)..."
|
||||
# The guest cannot clone anymore, so stage the push from a copy of bot1's
|
||||
# member clone, credentialed as the guest. (SEC-005 denies the push at the
|
||||
# receive-pack advertisement, before any ref negotiation, so the copy being
|
||||
# behind the remote does not matter.)
|
||||
rm -rf "$GUEST_DIR"
|
||||
cp -R "$BOT1_DIR" "$GUEST_DIR"
|
||||
|
||||
echo "<!-- unauthorized -->" >> "$GUEST_DIR/index.html"
|
||||
git -C "$GUEST_DIR" add -A
|
||||
git -C "$GUEST_DIR" -c user.name="Guest" -c user.email="guest@evil.test" \
|
||||
commit -m "Unauthorized change"
|
||||
|
||||
PUSH_OUTPUT=$(git_push "$GUEST_PRIVKEY" "$GUEST_DIR" 2>&1) && \
|
||||
fail "Guest push succeeded (should have been denied!)"
|
||||
|
||||
# Verify the denial is permission-related, not a network error. With the
|
||||
# SEC-005 read gate the denial surfaces at the receive-pack advertisement
|
||||
# as a generic "repository not found" (membership must not be probeable);
|
||||
# hook-level denials phrase it as denied/forbidden.
|
||||
if echo "$PUSH_OUTPUT" | grep -qi "denied\|forbidden\|not authorized\|403\|permission\|not found\|404"; then
|
||||
success "Guest push denied (not a channel member) — reason confirmed in output"
|
||||
else
|
||||
warn "Guest push failed but denial reason not found in output: $PUSH_OUTPUT"
|
||||
success "Guest push denied (non-zero exit)"
|
||||
fi
|
||||
|
||||
# ── Final verification ────────────────────────────────────────────────────────
|
||||
|
||||
log "Verifying final repo state..."
|
||||
VERIFY_DIR="$WORK_DIR/verify"
|
||||
|
||||
git_clone "$OWNER_PRIVKEY" "${RELAY_HTTP}/git/${OWNER_PUBKEY}/${REPO_NAME}" "$VERIFY_DIR" \
|
||||
|| fail "Owner clone for verification failed"
|
||||
|
||||
if grep -q "Bot 1" "$VERIFY_DIR/index.html" && grep -q "Bot 2" "$VERIFY_DIR/index.html"; then
|
||||
success "Final repo contains both bots' contributions"
|
||||
else
|
||||
fail "Final repo missing expected content"
|
||||
fi
|
||||
|
||||
log "Commit log:"
|
||||
git -C "$VERIFY_DIR" log --oneline
|
||||
|
||||
success "=== PHASE 1 COMPLETE: Transport + RBAC ==="
|
||||
|
||||
# =============================================================================
|
||||
# PHASE 2 — Commit Signing (NIP-GS)
|
||||
# =============================================================================
|
||||
|
||||
SIGNER="${REPO_ROOT}/target/release/git-sign-nostr"
|
||||
|
||||
if [[ ! -x "$SIGNER" ]]; then
|
||||
warn "git-sign-nostr not built — skipping signing tests"
|
||||
warn "Build with: cargo build --release --bin git-sign-nostr"
|
||||
else
|
||||
|
||||
# ── Test: Unsigned commit pushes fine (advisory model) ────────────────────────
|
||||
# WHY: NIP-GS signing is client-side provenance only. The relay does NOT enforce
|
||||
# signatures on push — any authenticated member can push unsigned commits.
|
||||
# This is intentional: signing proves authorship to verifiers, but the relay's
|
||||
# job is authorization (channel membership + branch protection), not signature
|
||||
# enforcement.
|
||||
|
||||
log "Advisory model: unsigned commit should push successfully..."
|
||||
UNSIGNED_DIR="$WORK_DIR/unsigned"
|
||||
|
||||
git_clone "$BOT1_PRIVKEY" "${RELAY_HTTP}/git/${OWNER_PUBKEY}/${REPO_NAME}" "$UNSIGNED_DIR" \
|
||||
|| fail "Clone for unsigned test failed"
|
||||
|
||||
echo "<!-- unsigned change -->" >> "$UNSIGNED_DIR/index.html"
|
||||
git -C "$UNSIGNED_DIR" add -A
|
||||
git -C "$UNSIGNED_DIR" -c user.name="Bot1" -c user.email="bot1@buzz.test" \
|
||||
commit -m "Unsigned commit (no gpgsign)"
|
||||
|
||||
if git_push "$BOT1_PRIVKEY" "$UNSIGNED_DIR"; then
|
||||
success "Unsigned commit pushed (signing is advisory, not enforced server-side)"
|
||||
else
|
||||
fail "Unsigned commit push failed — server should not enforce signing"
|
||||
fi
|
||||
|
||||
# ── Test: Signed commit with git-sign-nostr ───────────────────────────────────
|
||||
|
||||
log "Signing: configuring git-sign-nostr and making a signed commit..."
|
||||
SIGNED_DIR="$WORK_DIR/signed"
|
||||
|
||||
git_clone "$BOT1_PRIVKEY" "${RELAY_HTTP}/git/${OWNER_PUBKEY}/${REPO_NAME}" "$SIGNED_DIR" \
|
||||
|| fail "Clone for signed test failed"
|
||||
|
||||
echo "<!-- signed by bot1 -->" >> "$SIGNED_DIR/index.html"
|
||||
git -C "$SIGNED_DIR" add -A
|
||||
|
||||
NOSTR_PRIVATE_KEY="$BOT1_PRIVKEY" \
|
||||
git -C "$SIGNED_DIR" \
|
||||
-c user.name="Bot1" \
|
||||
-c user.email="bot1@buzz.test" \
|
||||
-c gpg.format=x509 \
|
||||
-c "gpg.x509.program=$SIGNER" \
|
||||
-c commit.gpgsign=true \
|
||||
-c "user.signingkey=$BOT1_PUBKEY" \
|
||||
commit -m "Signed commit via NIP-GS"
|
||||
|
||||
# Verify the signature locally
|
||||
log "Verifying signature with git verify-commit..."
|
||||
if NOSTR_PRIVATE_KEY="$BOT1_PRIVKEY" \
|
||||
git -C "$SIGNED_DIR" \
|
||||
-c gpg.format=x509 \
|
||||
-c "gpg.x509.program=$SIGNER" \
|
||||
-c "user.signingkey=$BOT1_PUBKEY" \
|
||||
verify-commit HEAD 2>&1; then
|
||||
success "git verify-commit succeeded (GOODSIG)"
|
||||
else
|
||||
fail "git verify-commit failed"
|
||||
fi
|
||||
|
||||
# Push the signed commit
|
||||
log "Pushing signed commit..."
|
||||
if git_push "$BOT1_PRIVKEY" "$SIGNED_DIR"; then
|
||||
success "Signed commit pushed successfully"
|
||||
else
|
||||
fail "Signed commit push failed"
|
||||
fi
|
||||
|
||||
# ── Test: Signed commit with owner attestation (NIP-OA) ──────────────────────
|
||||
|
||||
log "Signing with owner attestation (BUZZ_AUTH_TAG)..."
|
||||
OA_DIR="$WORK_DIR/oa-signed"
|
||||
|
||||
git_clone "$BOT1_PRIVKEY" "${RELAY_HTTP}/git/${OWNER_PUBKEY}/${REPO_NAME}" "$OA_DIR" \
|
||||
|| fail "Clone for OA test failed"
|
||||
|
||||
echo "<!-- signed with oa -->" >> "$OA_DIR/index.html"
|
||||
git -C "$OA_DIR" add -A
|
||||
|
||||
# Generate a NIP-OA auth tag: owner authorizes bot1
|
||||
OA_TAG=$(python3 << PYEOF
|
||||
import hashlib, json
|
||||
|
||||
P = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEFFFFFC2F
|
||||
N = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141
|
||||
Gx = 0x79BE667EF9DCBBAC55A06295CE870B07029BFCDB2DCE28D959F2815B16F81798
|
||||
Gy = 0x483ADA7726A3C4655DA4FBFC0E1108A8FD17B448A68554199C47D08FFB10D4B8
|
||||
|
||||
def point_add(p1, p2):
|
||||
if p1 is None: return p2
|
||||
if p2 is None: return p1
|
||||
x1, y1 = p1; x2, y2 = p2
|
||||
if x1 == x2 and y1 != y2: return None
|
||||
if x1 == x2: lam = (3*x1*x1) * pow(2*y1, P-2, P) % P
|
||||
else: lam = (y2-y1) * pow(x2-x1, P-2, P) % P
|
||||
x3 = (lam*lam - x1 - x2) % P
|
||||
y3 = (lam*(x1-x3) - y1) % P
|
||||
return (x3, y3)
|
||||
|
||||
def scalar_mult(k, point):
|
||||
result = None; addend = point
|
||||
while k:
|
||||
if k & 1: result = point_add(result, addend)
|
||||
addend = point_add(addend, addend)
|
||||
k >>= 1
|
||||
return result
|
||||
|
||||
def sign_schnorr(privkey_hex, msg_bytes):
|
||||
"""BIP-340 Schnorr signing — TEST ONLY."""
|
||||
k_int = int(privkey_hex, 16)
|
||||
pubpoint = scalar_mult(k_int, (Gx, Gy))
|
||||
pubkey_bytes = pubpoint[0].to_bytes(32, 'big')
|
||||
if pubpoint[1] % 2 != 0:
|
||||
k_int = N - k_int
|
||||
nonce_hash = hashlib.sha256(k_int.to_bytes(32, 'big') + msg_bytes).digest()
|
||||
r_int = int.from_bytes(nonce_hash, 'big') % N
|
||||
if r_int == 0: raise Exception("bad nonce")
|
||||
R = scalar_mult(r_int, (Gx, Gy))
|
||||
if R[1] % 2 != 0:
|
||||
r_int = N - r_int
|
||||
R_bytes = R[0].to_bytes(32, 'big')
|
||||
tag_hash = hashlib.sha256(b'BIP0340/challenge').digest()
|
||||
e_hash = hashlib.sha256(tag_hash + tag_hash + R_bytes + pubkey_bytes + msg_bytes).digest()
|
||||
e_int = int.from_bytes(e_hash, 'big') % N
|
||||
s_int = (r_int + e_int * k_int) % N
|
||||
return (R_bytes + s_int.to_bytes(32, 'big')).hex()
|
||||
|
||||
owner_privkey = "${OWNER_PRIVKEY}"
|
||||
bot1_pubkey = "${BOT1_PUBKEY}"
|
||||
owner_pubpoint = scalar_mult(int(owner_privkey, 16), (Gx, Gy))
|
||||
owner_pubkey = format(owner_pubpoint[0], '064x')
|
||||
|
||||
preimage = f"nostr:agent-auth:{bot1_pubkey}:"
|
||||
msg = hashlib.sha256(preimage.encode()).digest()
|
||||
sig = sign_schnorr(owner_privkey, msg)
|
||||
|
||||
print(json.dumps(["auth", owner_pubkey, "", sig]))
|
||||
PYEOF
|
||||
)
|
||||
|
||||
NOSTR_PRIVATE_KEY="$BOT1_PRIVKEY" BUZZ_AUTH_TAG="$OA_TAG" \
|
||||
git -C "$OA_DIR" \
|
||||
-c user.name="Bot1" \
|
||||
-c user.email="bot1@buzz.test" \
|
||||
-c gpg.format=x509 \
|
||||
-c "gpg.x509.program=$SIGNER" \
|
||||
-c commit.gpgsign=true \
|
||||
-c "user.signingkey=$BOT1_PUBKEY" \
|
||||
commit -m "Signed commit with owner attestation"
|
||||
|
||||
# Verify the oa field is present in the signature
|
||||
COMMIT_SIG=$(git -C "$OA_DIR" cat-file commit HEAD | sed -n '/^gpgsig /,/^[^ ]/{ /^gpgsig /d; /^[^ ]/d; s/^ //; p; }')
|
||||
DECODED_SIG=$(echo "$COMMIT_SIG" | base64 -d 2>/dev/null || echo "$COMMIT_SIG" | base64 -D 2>/dev/null)
|
||||
if echo "$DECODED_SIG" | grep -q '"oa"'; then
|
||||
success "Owner attestation (oa field) present in signature"
|
||||
else
|
||||
fail "Owner attestation missing from signature — BUZZ_AUTH_TAG not picked up"
|
||||
fi
|
||||
|
||||
# Push it
|
||||
if git_push "$BOT1_PRIVKEY" "$OA_DIR"; then
|
||||
success "Signed commit with oa pushed successfully"
|
||||
else
|
||||
fail "Signed commit with oa push failed"
|
||||
fi
|
||||
|
||||
success "=== PHASE 2 COMPLETE: Commit Signing ==="
|
||||
|
||||
fi # end git-sign-nostr check
|
||||
|
||||
# =============================================================================
|
||||
# PHASE 3 — Auth Bypass Tests
|
||||
# =============================================================================
|
||||
|
||||
log "Auth bypass: testing unauthenticated access..."
|
||||
|
||||
GIT_INFO_URL="${RELAY_HTTP}/git/${OWNER_PUBKEY}/${REPO_NAME}/info/refs?service=git-upload-pack"
|
||||
|
||||
# ── Test: No auth header → 401 ───────────────────────────────────────────────
|
||||
|
||||
HTTP_CODE=$(curl -sf --max-time "$CURL_TIMEOUT" -o /dev/null -w "%{http_code}" "$GIT_INFO_URL" || true)
|
||||
if [[ "$HTTP_CODE" == "401" ]]; then
|
||||
success "No auth header → 401 (unauthenticated clone rejected)"
|
||||
else
|
||||
fail "No auth header got HTTP $HTTP_CODE (expected 401)"
|
||||
fi
|
||||
|
||||
# ── Test: Malformed auth header → 401 ────────────────────────────────────────
|
||||
|
||||
HTTP_CODE=$(curl -sf --max-time "$CURL_TIMEOUT" -o /dev/null -w "%{http_code}" \
|
||||
-H "Authorization: Bearer invalid-token" "$GIT_INFO_URL" || true)
|
||||
if [[ "$HTTP_CODE" == "401" ]]; then
|
||||
success "Malformed auth (Bearer instead of Nostr) → 401"
|
||||
else
|
||||
fail "Malformed auth got HTTP $HTTP_CODE (expected 401)"
|
||||
fi
|
||||
|
||||
# ── Test: Garbage base64 in Nostr auth → 401 ─────────────────────────────────
|
||||
|
||||
HTTP_CODE=$(curl -sf --max-time "$CURL_TIMEOUT" -o /dev/null -w "%{http_code}" \
|
||||
-H "Authorization: Nostr dGhpcyBpcyBub3QgYSB2YWxpZCBldmVudA==" "$GIT_INFO_URL" || true)
|
||||
if [[ "$HTTP_CODE" == "401" ]]; then
|
||||
success "Garbage Nostr token → 401"
|
||||
else
|
||||
fail "Garbage Nostr token got HTTP $HTTP_CODE (expected 401)"
|
||||
fi
|
||||
|
||||
# ── Test: HMAC tampering — forged policy callback rejected ────────────────────
|
||||
# ATTACK: An attacker who can reach the policy endpoint tries to forge a hook
|
||||
# callback with a wrong HMAC to authorize an unauthorized push.
|
||||
|
||||
log "Auth bypass: forged HMAC on policy endpoint..."
|
||||
FORGED_TIMESTAMP=$(date +%s)
|
||||
FORGED_PAYLOAD=$(cat << JSONEOF
|
||||
{"repo_id":"${REPO_NAME}","repo_owner":"${OWNER_PUBKEY}","pusher_pubkey":"${GUEST_PUBKEY}","ref_updates":[{"old_oid":"0000000000000000000000000000000000000000","new_oid":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","ref_name":"refs/heads/main","is_ancestor":false}],"timestamp":${FORGED_TIMESTAMP},"signature":"deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef"}
|
||||
JSONEOF
|
||||
)
|
||||
|
||||
HTTP_CODE=$(curl -sf --max-time "$CURL_TIMEOUT" -o /dev/null -w "%{http_code}" \
|
||||
-X POST \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$FORGED_PAYLOAD" \
|
||||
"${RELAY_HTTP}/internal/git/policy" || true)
|
||||
if [[ "$HTTP_CODE" == "403" || "$HTTP_CODE" == "401" ]]; then
|
||||
success "Forged HMAC on policy endpoint → rejected ($HTTP_CODE)"
|
||||
else
|
||||
fail "Forged HMAC got HTTP $HTTP_CODE (expected 403 or 401)"
|
||||
fi
|
||||
|
||||
success "=== PHASE 3 COMPLETE: Auth Bypass + HMAC ==="
|
||||
|
||||
# =============================================================================
|
||||
# PHASE 4 — Hook Integrity
|
||||
# =============================================================================
|
||||
|
||||
# ── Test: Hook is a regular file (not symlink) ────────────────────────────────
|
||||
|
||||
log "Hook integrity: verifying hook is regular file..."
|
||||
if [[ -f "$HOOK_PATH" && ! -L "$HOOK_PATH" ]]; then
|
||||
success "Pre-receive hook is a regular file (not a symlink)"
|
||||
else
|
||||
fail "Pre-receive hook is missing or is a symlink"
|
||||
fi
|
||||
|
||||
# ── Test: Hook is executable ──────────────────────────────────────────────────
|
||||
|
||||
if [[ -x "$HOOK_PATH" ]]; then
|
||||
success "Pre-receive hook is executable"
|
||||
else
|
||||
fail "Pre-receive hook is not executable"
|
||||
fi
|
||||
|
||||
# ── Test: Symlink hook rejected by relay ──────────────────────────────────────
|
||||
# Use a subshell with trap to guarantee hook restoration even on failure.
|
||||
|
||||
log "Hook integrity: testing symlink hook rejection..."
|
||||
(
|
||||
# Backup and replace with symlink
|
||||
cp "$HOOK_PATH" "${HOOK_PATH}.bak"
|
||||
trap 'rm -f "$HOOK_PATH"; mv "${HOOK_PATH}.bak" "$HOOK_PATH"' EXIT
|
||||
|
||||
rm "$HOOK_PATH"
|
||||
ln -s /dev/null "$HOOK_PATH"
|
||||
|
||||
SYMLINK_DIR="$WORK_DIR/symlink-test"
|
||||
git_clone "$BOT1_PRIVKEY" "${RELAY_HTTP}/git/${OWNER_PUBKEY}/${REPO_NAME}" "$SYMLINK_DIR" \
|
||||
|| exit 1
|
||||
|
||||
echo "<!-- symlink test -->" >> "$SYMLINK_DIR/index.html"
|
||||
git -C "$SYMLINK_DIR" add -A
|
||||
git -C "$SYMLINK_DIR" -c user.name="Bot1" -c user.email="bot1@buzz.test" \
|
||||
commit -m "Symlink hook test"
|
||||
|
||||
if git_push "$BOT1_PRIVKEY" "$SYMLINK_DIR" 2>&1; then
|
||||
echo "PUSH_SUCCEEDED"
|
||||
exit 2 # Signal that push should have failed
|
||||
else
|
||||
echo "PUSH_DENIED"
|
||||
exit 0
|
||||
fi
|
||||
)
|
||||
SYMLINK_RESULT=$?
|
||||
if [[ $SYMLINK_RESULT -eq 0 ]]; then
|
||||
success "Push denied with symlink hook (relay detected tampering)"
|
||||
elif [[ $SYMLINK_RESULT -eq 2 ]]; then
|
||||
fail "Push succeeded with symlink hook (should have been rejected)"
|
||||
else
|
||||
fail "Symlink hook test failed unexpectedly (exit $SYMLINK_RESULT)"
|
||||
fi
|
||||
|
||||
# ── Test: Missing hook rejected by relay ──────────────────────────────────────
|
||||
|
||||
log "Hook integrity: testing missing hook rejection..."
|
||||
(
|
||||
mv "$HOOK_PATH" "${HOOK_PATH}.bak"
|
||||
trap 'mv "${HOOK_PATH}.bak" "$HOOK_PATH"' EXIT
|
||||
|
||||
MISSING_DIR="$WORK_DIR/missing-hook-test"
|
||||
git_clone "$BOT1_PRIVKEY" "${RELAY_HTTP}/git/${OWNER_PUBKEY}/${REPO_NAME}" "$MISSING_DIR" \
|
||||
|| exit 1
|
||||
|
||||
echo "<!-- missing hook test -->" >> "$MISSING_DIR/index.html"
|
||||
git -C "$MISSING_DIR" add -A
|
||||
git -C "$MISSING_DIR" -c user.name="Bot1" -c user.email="bot1@buzz.test" \
|
||||
commit -m "Missing hook test"
|
||||
|
||||
if git_push "$BOT1_PRIVKEY" "$MISSING_DIR" 2>&1; then
|
||||
exit 2 # Push should have failed
|
||||
else
|
||||
exit 0
|
||||
fi
|
||||
)
|
||||
MISSING_RESULT=$?
|
||||
if [[ $MISSING_RESULT -eq 0 ]]; then
|
||||
success "Push denied with missing hook (fail-closed)"
|
||||
elif [[ $MISSING_RESULT -eq 2 ]]; then
|
||||
fail "Push succeeded with missing hook (should have been rejected)"
|
||||
else
|
||||
fail "Missing hook test failed unexpectedly (exit $MISSING_RESULT)"
|
||||
fi
|
||||
|
||||
# ── Test: Client-side core.hooksPath cannot bypass server hook ────────────────
|
||||
# ATTACK: A malicious repo could set core.hooksPath in its .git/config to point
|
||||
# to /dev/null or a no-op script. This only affects client-side hooks — the
|
||||
# server's pre-receive hook is invoked by git-receive-pack on the SERVER, not
|
||||
# the client. This test confirms server-side authorization still fires.
|
||||
# (Clone as bot1 — the SEC-005 read gate denies guest clones — then push as
|
||||
# the guest: the unauthorized push must be denied regardless of client config,
|
||||
# whether at the read gate or the server hook.)
|
||||
|
||||
log "Hook integrity: client core.hooksPath cannot bypass server hook..."
|
||||
HOOKSPATH_DIR="$WORK_DIR/hookspath-test"
|
||||
|
||||
NOSTR_PRIVATE_KEY="$BOT1_PRIVKEY" GIT_TERMINAL_PROMPT=0 \
|
||||
git clone \
|
||||
-c credential.helper="" \
|
||||
-c credential.useHttpPath=true \
|
||||
-c "credential.${RELAY_HTTP}.helper=${CRED_HELPER}" \
|
||||
-c core.hooksPath=/dev/null \
|
||||
"${RELAY_HTTP}/git/${OWNER_PUBKEY}/${REPO_NAME}" \
|
||||
"$HOOKSPATH_DIR" 2>&1 || fail "Clone for hooksPath test failed"
|
||||
|
||||
echo "<!-- hooksPath bypass attempt -->" >> "$HOOKSPATH_DIR/index.html"
|
||||
git -C "$HOOKSPATH_DIR" add -A
|
||||
git -C "$HOOKSPATH_DIR" \
|
||||
-c user.name="Guest" -c user.email="guest@evil.test" \
|
||||
-c core.hooksPath=/dev/null \
|
||||
commit -m "Attempt to bypass server hook via client hooksPath"
|
||||
|
||||
PUSH_OUTPUT=$(NOSTR_PRIVATE_KEY="$GUEST_PRIVKEY" GIT_TERMINAL_PROMPT=0 \
|
||||
git -C "$HOOKSPATH_DIR" \
|
||||
-c credential.helper="" \
|
||||
-c credential.useHttpPath=true \
|
||||
-c "credential.${RELAY_HTTP}.helper=${CRED_HELPER}" \
|
||||
-c core.hooksPath=/dev/null \
|
||||
push 2>&1) && \
|
||||
fail "Push succeeded with client hooksPath override (server hook should still deny)"
|
||||
|
||||
success "Client core.hooksPath=/dev/null does NOT bypass server-side pre-receive hook"
|
||||
|
||||
success "=== PHASE 4 COMPLETE: Hook Integrity ==="
|
||||
|
||||
# =============================================================================
|
||||
# DONE
|
||||
# =============================================================================
|
||||
|
||||
printf "\n"
|
||||
printf "${GREEN}════════════════════════════════════════════════════════${NC}\n"
|
||||
printf "${GREEN} All E2E tests passed!${NC}\n"
|
||||
printf "${GREEN} • Transport + RBAC (clone, push, deny)${NC}\n"
|
||||
printf "${GREEN} • Commit Signing (NIP-GS sign + verify + oa)${NC}\n"
|
||||
printf "${GREEN} • Auth Bypass (no auth, malformed, garbage, HMAC)${NC}\n"
|
||||
printf "${GREEN} • Hook Integrity (symlink, missing → denied)${NC}\n"
|
||||
printf "${GREEN}════════════════════════════════════════════════════════${NC}\n"
|
||||
printf "\n"
|
||||
@@ -0,0 +1,48 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
BACKEND="${1:-}"
|
||||
if [[ -z "$BACKEND" ]]; then
|
||||
case "$(uname -s)" in
|
||||
Darwin) BACKEND="metal" ;;
|
||||
*) BACKEND="cpu" ;;
|
||||
esac
|
||||
fi
|
||||
|
||||
CACHE_DIR="${MESH_LLM_NATIVE_RUNTIME_CACHE_DIR:-$ROOT/.cache/mesh-llm-native-runtime}"
|
||||
OUT_DIR="${MESH_LLM_NATIVE_RUNTIME_OUT_DIR:-$ROOT/.cache/mesh-llm-native-runtime-artifacts}"
|
||||
|
||||
metadata="$($ROOT/bin/cargo metadata --manifest-path "$ROOT/desktop/src-tauri/Cargo.toml" --features mesh-llm --format-version 1)"
|
||||
SDK_MANIFEST="$(python3 -c 'import json,sys; data=json.load(sys.stdin); print(next(p["manifest_path"] for p in data["packages"] if p["name"]=="mesh-llm-sdk"))' <<<"$metadata")"
|
||||
MESH_ROOT="$(cd "$(dirname "$SDK_MANIFEST")/../.." && pwd)"
|
||||
MESH_VERSION="$(python3 -c 'import json,sys; data=json.load(sys.stdin); print(next(p["version"] for p in data["packages"] if p["name"]=="mesh-llm-sdk"))' <<<"$metadata")"
|
||||
|
||||
case "$(uname -s)/$(uname -m)/$BACKEND" in
|
||||
Darwin/arm64/metal) RUNTIME_ID="meshllm-native-runtime-darwin-aarch64-metal" ;;
|
||||
Darwin/x86_64/metal) RUNTIME_ID="meshllm-native-runtime-darwin-x86_64-metal" ;;
|
||||
*/*/cpu)
|
||||
case "$(uname -s)/$(uname -m)" in
|
||||
Darwin/arm64) RUNTIME_ID="meshllm-native-runtime-darwin-aarch64-cpu" ;;
|
||||
Darwin/x86_64) RUNTIME_ID="meshllm-native-runtime-darwin-x86_64-cpu" ;;
|
||||
Linux/x86_64) RUNTIME_ID="meshllm-native-runtime-linux-x86_64-cpu" ;;
|
||||
Linux/aarch64) RUNTIME_ID="meshllm-native-runtime-linux-aarch64-cpu" ;;
|
||||
*) RUNTIME_ID="" ;;
|
||||
esac
|
||||
;;
|
||||
*) RUNTIME_ID="" ;;
|
||||
esac
|
||||
|
||||
if [[ -n "$RUNTIME_ID" && -f "$CACHE_DIR/$MESH_VERSION/$RUNTIME_ID/manifest.json" ]]; then
|
||||
printf '%s\n' "$CACHE_DIR"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Preparing MeshLLM native runtime ($BACKEND) for MeshLLM $MESH_VERSION..." >&2
|
||||
runtime_dir="$(cd "$MESH_ROOT" && scripts/ci-prepare-native-runtime.sh "$OUT_DIR" "$BACKEND")"
|
||||
version="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["runtime"].get("mesh_version") or "unknown")' "$runtime_dir/manifest.json")"
|
||||
id="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["runtime"]["id"])' "$runtime_dir/manifest.json")"
|
||||
mkdir -p "$CACHE_DIR/$version"
|
||||
rm -rf "$CACHE_DIR/$version/$id"
|
||||
cp -a "$runtime_dir" "$CACHE_DIR/$version/$id"
|
||||
printf '%s\n' "$CACHE_DIR"
|
||||
@@ -0,0 +1,213 @@
|
||||
#!/usr/bin/env swift
|
||||
|
||||
import AppKit
|
||||
import Foundation
|
||||
|
||||
// Generate a dev icon with worktree name badge
|
||||
// Usage: generate-dev-icon.swift <input-icns> <output-icns> <label>
|
||||
|
||||
guard CommandLine.arguments.count == 4 else {
|
||||
fputs("Usage: \(CommandLine.arguments[0]) <input-icns> <output-icns> <label>\n", stderr)
|
||||
exit(1)
|
||||
}
|
||||
|
||||
let inputPath = CommandLine.arguments[1]
|
||||
let outputPath = CommandLine.arguments[2]
|
||||
let label = CommandLine.arguments[3]
|
||||
|
||||
func sanitizedPathComponent(_ value: String) -> String {
|
||||
let lowered = value.lowercased()
|
||||
let sanitizedScalars = lowered.unicodeScalars.map { scalar -> Character in
|
||||
switch scalar {
|
||||
case "a"..."z", "0"..."9":
|
||||
return Character(scalar)
|
||||
default:
|
||||
return "-"
|
||||
}
|
||||
}
|
||||
let collapsed = String(sanitizedScalars).replacingOccurrences(
|
||||
of: "-+",
|
||||
with: "-",
|
||||
options: .regularExpression
|
||||
)
|
||||
let trimmed = collapsed.trimmingCharacters(in: CharacterSet(charactersIn: "-"))
|
||||
return trimmed.isEmpty ? "dev" : trimmed
|
||||
}
|
||||
|
||||
guard let iconImage = NSImage(contentsOfFile: inputPath) else {
|
||||
fputs("Failed to load image: \(inputPath)\n", stderr)
|
||||
exit(1)
|
||||
}
|
||||
|
||||
// Get the largest representation for best quality
|
||||
guard let rep = iconImage.representations.max(by: { $0.pixelsWide < $1.pixelsWide }) else {
|
||||
fputs("No image representations found\n", stderr)
|
||||
exit(1)
|
||||
}
|
||||
|
||||
let size = NSSize(width: rep.pixelsWide, height: rep.pixelsHigh)
|
||||
|
||||
let newImage = NSImage(size: size)
|
||||
newImage.lockFocus()
|
||||
|
||||
// Draw the original icon
|
||||
iconImage.draw(in: NSRect(origin: .zero, size: size))
|
||||
|
||||
// Configure badge
|
||||
let singleLineHeight = size.height * 0.22
|
||||
let padding = size.width * 0.03
|
||||
let maxBadgeWidth = size.width * 0.9
|
||||
|
||||
// Text attributes - calculate font size to fit
|
||||
let maxFontSize = singleLineHeight * 0.65
|
||||
var fontSize = maxFontSize
|
||||
var attributes: [NSAttributedString.Key: Any]
|
||||
|
||||
// Helper to wrap text on `-` characters
|
||||
func wrapText(_ text: String, maxWidth: CGFloat, attributes: [NSAttributedString.Key: Any]) -> [String] {
|
||||
let singleLineSize = (text as NSString).size(withAttributes: attributes)
|
||||
if singleLineSize.width <= maxWidth {
|
||||
return [text]
|
||||
}
|
||||
|
||||
// Split on `-` and try to form lines
|
||||
let parts = text.components(separatedBy: "-")
|
||||
if parts.count == 1 {
|
||||
return [text] // No `-` to wrap on
|
||||
}
|
||||
|
||||
var lines: [String] = []
|
||||
var currentLine = ""
|
||||
|
||||
for (index, part) in parts.enumerated() {
|
||||
let separator = index == 0 ? "" : "-"
|
||||
let testLine = currentLine.isEmpty ? part : currentLine + separator + part
|
||||
let testSize = (testLine as NSString).size(withAttributes: attributes)
|
||||
|
||||
if testSize.width <= maxWidth || currentLine.isEmpty {
|
||||
currentLine = testLine
|
||||
} else {
|
||||
lines.append(currentLine)
|
||||
currentLine = part
|
||||
}
|
||||
}
|
||||
if !currentLine.isEmpty {
|
||||
lines.append(currentLine)
|
||||
}
|
||||
|
||||
return lines
|
||||
}
|
||||
|
||||
// Find font size that fits (allowing up to 2 lines)
|
||||
var lines: [String] = []
|
||||
repeat {
|
||||
attributes = [
|
||||
.font: NSFont.systemFont(ofSize: fontSize, weight: .bold),
|
||||
.foregroundColor: NSColor.white
|
||||
]
|
||||
lines = wrapText(label, maxWidth: maxBadgeWidth - padding * 4, attributes: attributes)
|
||||
fontSize -= 1
|
||||
} while lines.count > 2 && fontSize > 8
|
||||
|
||||
// Calculate text dimensions using typographic metrics
|
||||
let lineHeight = (lines.first! as NSString).size(withAttributes: attributes).height
|
||||
let textHeight = lineHeight * CGFloat(lines.count)
|
||||
let maxLineWidth = lines.map { ($0 as NSString).size(withAttributes: attributes).width }.max() ?? 0
|
||||
|
||||
// Badge dimensions based on text
|
||||
let badgeHeight = textHeight + padding * 2
|
||||
let cornerRadius = badgeHeight * 0.2
|
||||
let badgeWidth = maxLineWidth + padding * 4
|
||||
let badgeX = (size.width - badgeWidth) / 2
|
||||
let badgeY = size.height - badgeHeight - padding - size.height * 0.05
|
||||
|
||||
// Draw badge background (light blue semi-transparent)
|
||||
let badgePath = NSBezierPath(roundedRect: NSRect(x: badgeX, y: badgeY, width: badgeWidth, height: badgeHeight),
|
||||
xRadius: cornerRadius, yRadius: cornerRadius)
|
||||
NSColor(calibratedRed: 0.4, green: 0.7, blue: 1.0, alpha: 0.85).setFill()
|
||||
badgePath.fill()
|
||||
|
||||
// Draw text centered in badge (multiple lines, bottom to top)
|
||||
for (index, line) in lines.reversed().enumerated() {
|
||||
let lineSize = (line as NSString).size(withAttributes: attributes)
|
||||
let textX = badgeX + (badgeWidth - lineSize.width) / 2
|
||||
let textY = badgeY + padding + lineHeight * CGFloat(index)
|
||||
(line as NSString).draw(at: NSPoint(x: textX, y: textY), withAttributes: attributes)
|
||||
}
|
||||
|
||||
newImage.unlockFocus()
|
||||
|
||||
// First, create PNG data at multiple sizes for icns
|
||||
guard let tiffData = newImage.tiffRepresentation,
|
||||
let bitmapRep = NSBitmapImageRep(data: tiffData) else {
|
||||
fputs("Failed to create bitmap representation\n", stderr)
|
||||
exit(1)
|
||||
}
|
||||
|
||||
// For simplicity, we'll create a PNG and then use iconutil if available,
|
||||
// or just save as PNG for the icon (Tauri can use PNG)
|
||||
guard let pngData = bitmapRep.representation(using: .png, properties: [:]) else {
|
||||
fputs("Failed to create PNG data\n", stderr)
|
||||
exit(1)
|
||||
}
|
||||
|
||||
// If output is .icns, we need to create an iconset and use iconutil
|
||||
if outputPath.hasSuffix(".icns") {
|
||||
let tempDir = FileManager.default.temporaryDirectory
|
||||
let iconsetParent = tempDir.appendingPathComponent(
|
||||
"staged-dev-\(sanitizedPathComponent(label))-\(UUID().uuidString)",
|
||||
isDirectory: true
|
||||
)
|
||||
let iconsetPath = iconsetParent.appendingPathComponent("icon.iconset", isDirectory: true)
|
||||
try! FileManager.default.createDirectory(at: iconsetPath, withIntermediateDirectories: true)
|
||||
|
||||
// Generate all required sizes for iconset
|
||||
let sizes: [(name: String, size: Int)] = [
|
||||
("icon_16x16", 16),
|
||||
("icon_16x16@2x", 32),
|
||||
("icon_32x32", 32),
|
||||
("icon_32x32@2x", 64),
|
||||
("icon_128x128", 128),
|
||||
("icon_128x128@2x", 256),
|
||||
("icon_256x256", 256),
|
||||
("icon_256x256@2x", 512),
|
||||
("icon_512x512", 512),
|
||||
("icon_512x512@2x", 1024)
|
||||
]
|
||||
|
||||
for (name, targetSize) in sizes {
|
||||
let resizedImage = NSImage(size: NSSize(width: targetSize, height: targetSize))
|
||||
resizedImage.lockFocus()
|
||||
NSGraphicsContext.current?.imageInterpolation = .high
|
||||
newImage.draw(in: NSRect(x: 0, y: 0, width: targetSize, height: targetSize))
|
||||
resizedImage.unlockFocus()
|
||||
|
||||
guard let resizedTiff = resizedImage.tiffRepresentation,
|
||||
let resizedBitmap = NSBitmapImageRep(data: resizedTiff),
|
||||
let resizedPng = resizedBitmap.representation(using: .png, properties: [:]) else {
|
||||
continue
|
||||
}
|
||||
|
||||
let filePath = iconsetPath.appendingPathComponent("\(name).png")
|
||||
try! resizedPng.write(to: filePath)
|
||||
}
|
||||
|
||||
// Use iconutil to create icns
|
||||
let process = Process()
|
||||
process.executableURL = URL(fileURLWithPath: "/usr/bin/iconutil")
|
||||
process.arguments = ["-c", "icns", iconsetPath.path, "-o", outputPath]
|
||||
try! process.run()
|
||||
process.waitUntilExit()
|
||||
|
||||
try? FileManager.default.removeItem(at: iconsetParent)
|
||||
|
||||
if process.terminationStatus != 0 {
|
||||
fputs("iconutil failed\n", stderr)
|
||||
exit(1)
|
||||
}
|
||||
} else {
|
||||
// Just save as PNG
|
||||
try! pngData.write(to: URL(fileURLWithPath: outputPath))
|
||||
}
|
||||
|
||||
print("Generated: \(outputPath)")
|
||||
@@ -0,0 +1,166 @@
|
||||
#!/usr/bin/env bash
|
||||
# grab-emoji.sh — Register custom Slack emoji in Buzz
|
||||
#
|
||||
# Looks up each emoji name in your Slack workspace and registers it in Buzz
|
||||
# via `buzz emoji set`, making it available as :name: in the Buzz emoji picker.
|
||||
#
|
||||
# Usage:
|
||||
# SLACK_TOKEN=xoxp-... ./scripts/grab-emoji.sh [--name <buzz-name>] <emoji-name> [emoji-name ...]
|
||||
#
|
||||
# Options:
|
||||
# --name <buzz-name> Override the shortcode used in Buzz (only valid with a single emoji)
|
||||
#
|
||||
# Env:
|
||||
# SLACK_TOKEN — Slack user token (xoxp-...) with emoji:read scope
|
||||
#
|
||||
# Output:
|
||||
# name → registered as :name: in Buzz on success
|
||||
# name → ERROR: reason on failure (script continues to next emoji)
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
CACHE_FILE="${HOME}/.cache/slack-emoji-list.json"
|
||||
CACHE_TTL=86400 # 24 hours in seconds
|
||||
|
||||
# ── Argument parsing ──────────────────────────────────────────────────────────
|
||||
|
||||
BUZZ_NAME=""
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--name)
|
||||
BUZZ_NAME="$2"
|
||||
shift 2
|
||||
;;
|
||||
--)
|
||||
shift
|
||||
break
|
||||
;;
|
||||
-*)
|
||||
echo "ERROR: Unknown option: $1" >&2
|
||||
exit 1
|
||||
;;
|
||||
*)
|
||||
break
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# ── Preflight checks ──────────────────────────────────────────────────────────
|
||||
|
||||
if [[ $# -eq 0 ]]; then
|
||||
echo "Usage: SLACK_TOKEN=xoxp-... $0 [--name <buzz-name>] <emoji-name> [emoji-name ...]" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ -n "$BUZZ_NAME" && $# -ne 1 ]]; then
|
||||
echo "ERROR: --name can only be used when specifying a single emoji" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ -z "${SLACK_TOKEN:-}" ]]; then
|
||||
echo "ERROR: SLACK_TOKEN is not set. Export your xoxp- Slack token." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! command -v buzz &>/dev/null; then
|
||||
echo "ERROR: 'buzz' not found in PATH. Install the Buzz CLI and retry." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! command -v curl &>/dev/null; then
|
||||
echo "ERROR: 'curl' not found in PATH." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! command -v jq &>/dev/null; then
|
||||
echo "ERROR: 'jq' not found in PATH." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ── Cache management ──────────────────────────────────────────────────────────
|
||||
|
||||
_cache_is_fresh() {
|
||||
[[ -f "$CACHE_FILE" ]] || return 1
|
||||
local mtime now age
|
||||
# macOS stat uses -f %m; GNU stat uses -c %Y
|
||||
if stat --version &>/dev/null 2>&1; then
|
||||
mtime=$(stat -c %Y "$CACHE_FILE")
|
||||
else
|
||||
mtime=$(stat -f %m "$CACHE_FILE")
|
||||
fi
|
||||
now=$(date +%s)
|
||||
age=$(( now - mtime ))
|
||||
(( age < CACHE_TTL ))
|
||||
}
|
||||
|
||||
_refresh_cache() {
|
||||
mkdir -p "$(dirname "$CACHE_FILE")"
|
||||
local response
|
||||
response=$(curl -sf \
|
||||
-H "Authorization: Bearer ${SLACK_TOKEN}" \
|
||||
"https://slack.com/api/emoji.list") || {
|
||||
echo "ERROR: network failure fetching emoji list from Slack" >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
local ok
|
||||
ok=$(echo "$response" | jq -r '.ok')
|
||||
if [[ "$ok" != "true" ]]; then
|
||||
local err
|
||||
err=$(echo "$response" | jq -r '.error // "unknown error"')
|
||||
echo "ERROR: Slack API returned error: $err" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
echo "$response" > "$CACHE_FILE"
|
||||
}
|
||||
|
||||
if ! _cache_is_fresh; then
|
||||
_refresh_cache || exit 1
|
||||
fi
|
||||
|
||||
# ── Emoji resolution ──────────────────────────────────────────────────────────
|
||||
|
||||
# Returns the URL for an emoji name, resolving one level of aliasing.
|
||||
# Prints the URL to stdout; returns 1 if not found.
|
||||
_resolve_url() {
|
||||
local name="$1"
|
||||
local value
|
||||
value=$(jq -r --arg n "$name" '.emoji[$n] // empty' "$CACHE_FILE")
|
||||
|
||||
if [[ -z "$value" ]]; then
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [[ "$value" == alias:* ]]; then
|
||||
local target="${value#alias:}"
|
||||
value=$(jq -r --arg n "$target" '.emoji[$n] // empty' "$CACHE_FILE")
|
||||
if [[ -z "$value" ]]; then
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "$value"
|
||||
}
|
||||
|
||||
# ── Per-emoji processing ──────────────────────────────────────────────────────
|
||||
|
||||
for emoji_name in "$@"; do
|
||||
# Use --name override if provided, otherwise use the Slack emoji name
|
||||
buzz_shortcode="${BUZZ_NAME:-$emoji_name}"
|
||||
|
||||
# Resolve URL
|
||||
emoji_url=$(_resolve_url "$emoji_name") || {
|
||||
echo "${emoji_name} → ERROR: emoji not found in workspace"
|
||||
continue
|
||||
}
|
||||
|
||||
# Register in Buzz
|
||||
set_output=$(buzz emoji set --shortcode "$buzz_shortcode" --url "$emoji_url" 2>&1) || {
|
||||
echo "${emoji_name} → ERROR: buzz emoji set failed — ${set_output}"
|
||||
continue
|
||||
}
|
||||
|
||||
echo "${emoji_name} → registered as :${buzz_shortcode}: in Buzz"
|
||||
done
|
||||
@@ -0,0 +1,97 @@
|
||||
#!/usr/bin/env bash
|
||||
# Computes the full multi-instance desktop dev environment.
|
||||
# Source this file from desktop dev commands; it exports:
|
||||
# BUZZ_VITE_PORT, BUZZ_HMR_PORT, VITE_PORT, VITE_HMR_PORT
|
||||
# BUZZ_RELAY_PORT, BUZZ_RELAY_URL
|
||||
# BUZZ_INSTANCE_SLUG, BUZZ_WORKTREE_LABEL, VITE_DEV_BRANCH (worktrees only)
|
||||
# BUZZ_TAURI_CONFIG
|
||||
# BUZZ_PRIVATE_KEY (worktrees only, when BUZZ_SHARE_IDENTITY=1)
|
||||
|
||||
WORKTREE_ROOT=$(git rev-parse --show-toplevel 2>/dev/null || pwd)
|
||||
|
||||
# Derive a stable base port from the worktree root so the same worktree always
|
||||
# gets the same ports. This keeps the Tauri dev config stable between runs and
|
||||
# preserves Cargo's build cache.
|
||||
BASE_PORT=$(python3 -c "import hashlib,sys; h=int(hashlib.sha256(sys.argv[1].encode()).hexdigest(), 16); print(10000 + h % 55000)" "$WORKTREE_ROOT")
|
||||
export BUZZ_VITE_PORT=$BASE_PORT
|
||||
export BUZZ_HMR_PORT=$((BASE_PORT + 1))
|
||||
export BUZZ_RELAY_PORT=3000
|
||||
export VITE_PORT="$BUZZ_VITE_PORT"
|
||||
export VITE_HMR_PORT="$BUZZ_HMR_PORT"
|
||||
export BUZZ_RELAY_URL="${BUZZ_RELAY_URL:-ws://localhost:3000}"
|
||||
|
||||
DEV_URL="http://localhost:${BUZZ_VITE_PORT}"
|
||||
if [[ "${BUZZ_RESET_WEBVIEW_STATE:-0}" == "1" ]]; then
|
||||
DEV_URL="${DEV_URL}?resetDevState=1"
|
||||
fi
|
||||
|
||||
BUZZ_TAURI_CONFIG="{\"build\":{\"devUrl\":\"${DEV_URL}\",\"beforeDevCommand\":\"exec ./node_modules/.bin/vite --port ${BUZZ_VITE_PORT} --strictPort\"},\"identifier\":\"xyz.block.buzz.app.dev\",\"productName\":\"Buzz Dev\"}"
|
||||
unset VITE_DEV_BRANCH
|
||||
|
||||
# In worktrees, extract a label from the branch name and derive a unique app
|
||||
# identity and icon so multiple local desktop instances can run side by side.
|
||||
#
|
||||
# Worktree detection: compare --git-dir to --git-common-dir. In the main
|
||||
# working tree these are identical; in any worktree (whether under .worktrees/,
|
||||
# .claude/worktrees/, or elsewhere on disk) they differ.
|
||||
if git rev-parse --is-inside-work-tree &>/dev/null; then
|
||||
GIT_DIR=$(git rev-parse --git-dir)
|
||||
GIT_COMMON_DIR=$(git rev-parse --git-common-dir 2>/dev/null)
|
||||
if [[ -n "$GIT_COMMON_DIR" && "$GIT_DIR" != "$GIT_COMMON_DIR" ]]; then
|
||||
BRANCH_NAME=$(git rev-parse --abbrev-ref HEAD)
|
||||
export BUZZ_WORKTREE_LABEL="${BRANCH_NAME##*/}"
|
||||
export BUZZ_INSTANCE_SLUG=$(echo "$BRANCH_NAME" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/-/g' | sed 's/--*/-/g' | sed 's/^-//' | sed 's/-$//')
|
||||
|
||||
# BUZZ_SHARE_IDENTITY=1: reuse the main dev checkout's Nostr key so
|
||||
# worktrees skip onboarding and share the same identity. The per-worktree
|
||||
# identifier is kept so concurrent instances don't collide on
|
||||
# tauri-plugin-single-instance or the app data directory.
|
||||
if [[ "${BUZZ_SHARE_IDENTITY:-0}" == "1" ]]; then
|
||||
KEYRING_SERVICE="buzz-desktop-dev"
|
||||
KEYRING_BLOB=""
|
||||
case "$(uname -s)" in
|
||||
Darwin)
|
||||
if command -v security &>/dev/null; then
|
||||
KEYRING_BLOB="$(security find-generic-password -s "$KEYRING_SERVICE" -a secrets -w 2>/dev/null || true)"
|
||||
fi
|
||||
;;
|
||||
Linux)
|
||||
if command -v secret-tool &>/dev/null; then
|
||||
KEYRING_BLOB="$(secret-tool lookup service "$KEYRING_SERVICE" username secrets target default 2>/dev/null || true)"
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
|
||||
KEYRING_IDENTITY="$(printf '%s' "$KEYRING_BLOB" | python3 -c 'import json, sys; value = json.load(sys.stdin).get("identity", ""); print(value if isinstance(value, str) else "")' 2>/dev/null || true)"
|
||||
CANONICAL_KEY="$HOME/Library/Application Support/xyz.block.buzz.app.dev/identity.key"
|
||||
LEGACY_CANONICAL_KEY="$HOME/Library/Application Support/xyz.block.sprout.app.dev/identity.key"
|
||||
|
||||
SHARED_IDENTITY="$KEYRING_IDENTITY"
|
||||
if [[ -z "$SHARED_IDENTITY" && -f "$CANONICAL_KEY" ]]; then
|
||||
SHARED_IDENTITY="$(cat "$CANONICAL_KEY")"
|
||||
elif [[ -z "$SHARED_IDENTITY" && -f "$LEGACY_CANONICAL_KEY" ]]; then
|
||||
SHARED_IDENTITY="$(cat "$LEGACY_CANONICAL_KEY")"
|
||||
fi
|
||||
|
||||
if [[ -n "$SHARED_IDENTITY" ]]; then
|
||||
export BUZZ_PRIVATE_KEY="$SHARED_IDENTITY"
|
||||
else
|
||||
echo "⚠ BUZZ_SHARE_IDENTITY=1 but no identity found in keyring service $KEYRING_SERVICE, at $CANONICAL_KEY, or at $LEGACY_CANONICAL_KEY — run Buzz from repo root first" >&2
|
||||
fi
|
||||
fi
|
||||
|
||||
ICON_DIR="$WORKTREE_ROOT/desktop/src-tauri/target/dev-icons"
|
||||
mkdir -p "$ICON_DIR"
|
||||
DEV_ICON="$ICON_DIR/icon.icns"
|
||||
GENERATE_DEV_ICON="$WORKTREE_ROOT/scripts/generate-dev-icon.swift"
|
||||
BASE_ICON="$WORKTREE_ROOT/desktop/src-tauri/icons/icon.icns"
|
||||
|
||||
if swift "$GENERATE_DEV_ICON" "$BASE_ICON" "$DEV_ICON" "$BUZZ_WORKTREE_LABEL"; then
|
||||
echo "🌳 Worktree: ${BUZZ_WORKTREE_LABEL}"
|
||||
export VITE_DEV_BRANCH="$BUZZ_WORKTREE_LABEL"
|
||||
BUZZ_TAURI_CONFIG="{\"build\":{\"devUrl\":\"${DEV_URL}\",\"beforeDevCommand\":\"exec ./node_modules/.bin/vite --port ${BUZZ_VITE_PORT} --strictPort\"},\"identifier\":\"xyz.block.buzz.app.dev.${BUZZ_INSTANCE_SLUG}\",\"productName\":\"Buzz Dev (${BUZZ_WORKTREE_LABEL})\",\"bundle\":{\"icon\":[\"$DEV_ICON\"]}}"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
export BUZZ_TAURI_CONFIG
|
||||
@@ -0,0 +1,20 @@
|
||||
-- OUT-OF-BAND MAINTENANCE: do not run from relay startup migrations.
|
||||
--
|
||||
-- This rewrites every events partition and rebuilds the partitioned GIN index.
|
||||
-- Run only in a maintenance window after confirming enough free space for the
|
||||
-- replacement heap/TOAST/index files plus WAL. ALTER TABLE takes ACCESS
|
||||
-- EXCLUSIVE, so event reads and writes block until this transaction commits.
|
||||
-- Consider combining this with the planned partition repack/reclaim operation.
|
||||
BEGIN;
|
||||
SET LOCAL lock_timeout = '5s';
|
||||
|
||||
ALTER TABLE events DROP COLUMN search_tsv;
|
||||
ALTER TABLE events ADD COLUMN search_tsv TSVECTOR GENERATED ALWAYS AS (
|
||||
CASE WHEN kind IN (0, 9, 40002, 45001, 45003)
|
||||
THEN to_tsvector('simple', content)
|
||||
ELSE NULL::tsvector
|
||||
END
|
||||
) STORED;
|
||||
CREATE INDEX idx_events_search_tsv ON events USING GIN (search_tsv);
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,169 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
usage() {
|
||||
cat >&2 <<'USAGE'
|
||||
usage:
|
||||
scripts/mobile-release.sh candidate X.Y.Z
|
||||
|
||||
candidate Publish the next immutable mobile-vX.Y.Z-rc.N candidate tag at the
|
||||
exact current commit of block/buzz's remote main branch.
|
||||
USAGE
|
||||
exit 2
|
||||
}
|
||||
|
||||
fail() {
|
||||
echo "Error: $*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
# shellcheck source=scripts/release-rulesets.sh
|
||||
source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/release-rulesets.sh"
|
||||
|
||||
require_gh_minimum_version() {
|
||||
local minimum="2.87.0" version
|
||||
|
||||
command -v gh >/dev/null 2>&1 || fail "gh >= $minimum is required"
|
||||
version="$(gh --version 2>/dev/null | awk 'NR == 1 { print $3 }')" || \
|
||||
fail "could not determine gh version (gh >= $minimum is required)"
|
||||
[[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || \
|
||||
fail "gh returned invalid version '$version'"
|
||||
if ! awk -v current="$version" -v minimum="$minimum" '
|
||||
BEGIN {
|
||||
split(current, c, ".")
|
||||
split(minimum, m, ".")
|
||||
for (i = 1; i <= 3; i++) {
|
||||
if ((c[i] + 0) > (m[i] + 0)) exit 0
|
||||
if ((c[i] + 0) < (m[i] + 0)) exit 1
|
||||
}
|
||||
exit 0
|
||||
}
|
||||
'; then
|
||||
fail "gh $version is too old; gh >= $minimum is required"
|
||||
fi
|
||||
}
|
||||
|
||||
require_clean_semver() {
|
||||
[[ "$1" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]] || \
|
||||
fail "'$1' is not a mobile release version (expected X.Y.Z)"
|
||||
}
|
||||
|
||||
require_clean_tree() {
|
||||
git diff --quiet && git diff --cached --quiet && \
|
||||
[[ -z "$(git status --short --untracked-files=normal)" ]] || \
|
||||
fail "working tree is dirty; commit or stash changes first"
|
||||
}
|
||||
|
||||
require_annotated_tag() {
|
||||
local git_dir="$1" object="$2" label="$3"
|
||||
if [[ "$(git -C "$git_dir" cat-file -t "$object" 2>/dev/null || true)" != "tag" ]]; then
|
||||
echo "$label must be an annotated tag" >&2
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
remote_tag_commit_sha() {
|
||||
local ref="$1" line advertised_oid tmp fetched_oid commit
|
||||
line="$(git ls-remote --refs origin "$ref")" || return 1
|
||||
[[ -n "$line" && "$line" != *$'\n'* ]] || return 1
|
||||
advertised_oid="${line%%$'\t'*}"
|
||||
tmp="$(mktemp -d)"
|
||||
git -C "$tmp" init -q
|
||||
git -C "$tmp" remote add origin "$(git remote get-url origin)"
|
||||
if ! git -C "$tmp" fetch -q --depth 1 origin "$ref"; then
|
||||
rm -rf "$tmp"
|
||||
return 1
|
||||
fi
|
||||
fetched_oid="$(git -C "$tmp" rev-parse --verify FETCH_HEAD)"
|
||||
if [[ "$fetched_oid" != "$advertised_oid" ]]; then
|
||||
rm -rf "$tmp"
|
||||
fail "$ref moved while it was being resolved"
|
||||
fi
|
||||
if ! require_annotated_tag "$tmp" FETCH_HEAD "$ref"; then
|
||||
rm -rf "$tmp"
|
||||
return 1
|
||||
fi
|
||||
if ! commit="$(git -C "$tmp" rev-parse --verify 'FETCH_HEAD^{commit}')"; then
|
||||
rm -rf "$tmp"
|
||||
return 1
|
||||
fi
|
||||
rm -rf "$tmp"
|
||||
printf '%s' "$commit"
|
||||
}
|
||||
|
||||
remote_main_commit_sha() {
|
||||
local ref="refs/heads/main" line advertised_oid fetched_oid commit
|
||||
line="$(git ls-remote --refs origin "$ref")" || return 1
|
||||
[[ -n "$line" && "$line" != *$'\n'* ]] || return 1
|
||||
advertised_oid="${line%%$'\t'*}"
|
||||
git fetch -q --no-tags origin "$ref"
|
||||
fetched_oid="$(git rev-parse --verify FETCH_HEAD)"
|
||||
[[ "$fetched_oid" == "$advertised_oid" ]] || \
|
||||
fail "origin/main moved while it was being resolved"
|
||||
commit="$(git rev-parse --verify 'FETCH_HEAD^{commit}')" || return 1
|
||||
[[ "$commit" == "$advertised_oid" ]] || \
|
||||
fail "origin/main did not resolve directly to a commit"
|
||||
printf '%s' "$commit"
|
||||
}
|
||||
|
||||
command="${1:-}"
|
||||
case "$command" in
|
||||
candidate)
|
||||
[[ "$#" -eq 2 ]] || usage
|
||||
version="$2"
|
||||
require_clean_semver "$version"
|
||||
require_clean_tree
|
||||
require_canonical_repository || exit 1
|
||||
require_gh_minimum_version
|
||||
|
||||
local_head_sha="$(git rev-parse --verify 'HEAD^{commit}')" || fail "HEAD is not a commit"
|
||||
main_sha="$(remote_main_commit_sha)" || fail "origin/main does not exist"
|
||||
next=1
|
||||
if ! remote_tags="$(git ls-remote --refs --tags origin "refs/tags/mobile-v${version}-rc.*")"; then
|
||||
fail "could not list existing candidates for $version"
|
||||
fi
|
||||
while IFS=$'\t' read -r _ ref; do
|
||||
[[ "$ref" =~ ^refs/tags/mobile-v${version//./\.}-rc\.([1-9][0-9]*)$ ]] || continue
|
||||
number="${BASH_REMATCH[1]}"
|
||||
(( number >= next )) && next=$((number + 1))
|
||||
done <<< "$remote_tags"
|
||||
|
||||
tag="mobile-v${version}-rc.${next}"
|
||||
workflow="mobile-release-candidate.yml"
|
||||
if dispatch_output="$(gh workflow run "$workflow" \
|
||||
--repo block/buzz \
|
||||
--ref main \
|
||||
-f "version=$version" \
|
||||
-f "candidate_number=$next" \
|
||||
-f "target_sha=$main_sha" 2>&1)"; then
|
||||
:
|
||||
else
|
||||
if [[ "$dispatch_output" == *"does not have 'workflow_dispatch' trigger"* ]]; then
|
||||
fail "$workflow is not available on main yet; merge the release-process change before publishing a candidate"
|
||||
fi
|
||||
fail "could not dispatch App-backed publication for $tag: $dispatch_output"
|
||||
fi
|
||||
run_url="$(printf '%s\n' "$dispatch_output" | awk '/^https:\/\/github\.com\/block\/buzz\/actions\/runs\/[0-9]+$/ { if (found) exit 2; found = $0 } END { if (found) print found }')" || \
|
||||
fail "GitHub returned multiple workflow run URLs for one candidate dispatch"
|
||||
[[ -n "$run_url" ]] || \
|
||||
fail "GitHub accepted the candidate dispatch but returned no workflow run URL"
|
||||
run_id="${run_url##*/}"
|
||||
gh run watch "$run_id" --repo block/buzz --exit-status --compact || \
|
||||
fail "App-backed publication failed: $run_url"
|
||||
|
||||
current_main_sha="$(remote_main_commit_sha)" || fail "origin/main does not exist after publication"
|
||||
[[ "$current_main_sha" == "$main_sha" ]] || \
|
||||
fail "origin/main moved from requested commit $main_sha to $current_main_sha during publication"
|
||||
published_sha="$(remote_tag_commit_sha "refs/tags/$tag")" || \
|
||||
fail "publication completed without exact annotated candidate tag $tag"
|
||||
[[ "$published_sha" == "$main_sha" ]] || \
|
||||
fail "$tag resolved to $published_sha instead of requested commit $main_sha"
|
||||
if [[ "$local_head_sha" != "$main_sha" ]]; then
|
||||
echo "Note: local HEAD is $local_head_sha; candidate source is current origin/main $main_sha." >&2
|
||||
fi
|
||||
printf 'Published %s at origin/main commit %s through buzz-release-bot. Use this exact tag in Release Mobile.\n' \
|
||||
"$tag" "$main_sha"
|
||||
;;
|
||||
|
||||
*) usage ;;
|
||||
esac
|
||||
@@ -0,0 +1,66 @@
|
||||
#!/usr/bin/env bash
|
||||
# Uninstalls stale worktree-suffixed Buzz debug builds from booted iOS
|
||||
# simulators and connected Android devices/emulators. Production installs
|
||||
# (com.buzz.buzzMobile / xyz.block.buzz.mobile, no suffix) are never touched:
|
||||
# only identifiers with a worktree suffix appended after the production id
|
||||
# are matched. Run `just mobile-clean` (or this script directly); pass
|
||||
# --dry-run to list what would be removed without uninstalling.
|
||||
set -euo pipefail
|
||||
|
||||
ios_prefix="com.buzz.buzzMobile."
|
||||
android_prefix="xyz.block.buzz.mobile."
|
||||
|
||||
dry_run=0
|
||||
if [[ "${1:-}" == "--dry-run" ]]; then
|
||||
dry_run=1
|
||||
fi
|
||||
|
||||
removed=0
|
||||
|
||||
# ── iOS: booted simulators only ──────────────────────────────────────────────
|
||||
if command -v xcrun &>/dev/null; then
|
||||
booted=$(xcrun simctl list devices booted 2>/dev/null | sed -n 's/.*(\([0-9A-F-]\{36\}\)) (Booted).*/\1/p' || true)
|
||||
for udid in $booted; do
|
||||
# `simctl listapps` emits a plist keyed by bundle id; extract keys
|
||||
# that carry the worktree suffix.
|
||||
bundles=$(xcrun simctl listapps "$udid" 2>/dev/null \
|
||||
| plutil -convert json -o - -- - 2>/dev/null \
|
||||
| python3 -c 'import json,sys; print("\n".join(k for k in json.load(sys.stdin) if k.startswith(sys.argv[1])))' "$ios_prefix" \
|
||||
|| true)
|
||||
for bundle in $bundles; do
|
||||
if [[ "$dry_run" == "1" ]]; then
|
||||
echo "would uninstall (iOS $udid): $bundle"
|
||||
else
|
||||
echo "uninstalling (iOS $udid): $bundle"
|
||||
xcrun simctl uninstall "$udid" "$bundle"
|
||||
fi
|
||||
removed=$((removed + 1))
|
||||
done
|
||||
done
|
||||
fi
|
||||
|
||||
# ── Android: all connected devices/emulators ────────────────────────────────
|
||||
if command -v adb &>/dev/null; then
|
||||
devices=$(adb devices 2>/dev/null | awk 'NR>1 && $2=="device" {print $1}' || true)
|
||||
for serial in $devices; do
|
||||
packages=$(adb -s "$serial" shell pm list packages 2>/dev/null \
|
||||
| tr -d '\r' | sed -n "s/^package:\(${android_prefix//./\\.}[a-z0-9_]*\)$/\1/p" || true)
|
||||
for pkg in $packages; do
|
||||
if [[ "$dry_run" == "1" ]]; then
|
||||
echo "would uninstall (Android $serial): $pkg"
|
||||
else
|
||||
echo "uninstalling (Android $serial): $pkg"
|
||||
adb -s "$serial" uninstall "$pkg" >/dev/null
|
||||
fi
|
||||
removed=$((removed + 1))
|
||||
done
|
||||
done
|
||||
fi
|
||||
|
||||
if [[ "$removed" == "0" ]]; then
|
||||
echo "no worktree-suffixed Buzz installs found (production apps untouched)"
|
||||
elif [[ "$dry_run" == "1" ]]; then
|
||||
echo "dry run: $removed worktree install(s) would be removed (production apps untouched)"
|
||||
else
|
||||
echo "removed $removed worktree install(s) (production apps untouched)"
|
||||
fi
|
||||
@@ -0,0 +1,93 @@
|
||||
#!/usr/bin/env bash
|
||||
# Writes worktree-aware identity overrides for mobile debug builds, mirroring
|
||||
# the desktop dev experience in scripts/instance-env.sh: debug builds produced
|
||||
# from a git worktree get a unique app identifier keyed to the WORKTREE
|
||||
# DIRECTORY NAME (stable across branch switches, so installs and login state
|
||||
# are bounded by worktree count) plus a display-only branch label (short SHA
|
||||
# when detached).
|
||||
#
|
||||
# In a worktree this writes two gitignored override files consumed by the
|
||||
# native build systems (so direct Xcode / Android Studio / `flutter run`
|
||||
# builds pick them up too):
|
||||
# mobile/ios/Flutter/WorktreeOverrides.xcconfig (Debug builds only; a
|
||||
# developer's AppOverrides.xcconfig takes precedence per variable)
|
||||
# mobile/android/worktree.properties (debug build type only)
|
||||
# In the main checkout it removes any stale override files. Release and
|
||||
# profile builds never read these overrides.
|
||||
set -euo pipefail
|
||||
|
||||
# `just mobile-*` recipes unset these before invoking Flutter; do the same so
|
||||
# an inherited GIT_DIR pointing elsewhere cannot misdetect the worktree.
|
||||
unset GIT_DIR GIT_WORK_TREE
|
||||
|
||||
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
ios_overrides="$repo_root/mobile/ios/Flutter/WorktreeOverrides.xcconfig"
|
||||
android_props="$repo_root/mobile/android/worktree.properties"
|
||||
|
||||
# Worktree detection: in the main working tree --git-dir and --git-common-dir
|
||||
# are identical; in any linked worktree they differ (same check as
|
||||
# scripts/instance-env.sh).
|
||||
in_worktree=0
|
||||
if git -C "$repo_root" rev-parse --is-inside-work-tree &>/dev/null; then
|
||||
git_dir=$(git -C "$repo_root" rev-parse --git-dir)
|
||||
git_common_dir=$(git -C "$repo_root" rev-parse --git-common-dir 2>/dev/null)
|
||||
if [[ -n "$git_common_dir" && "$git_dir" != "$git_common_dir" ]]; then
|
||||
in_worktree=1
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ "$in_worktree" != "1" ]]; then
|
||||
rm -f "$ios_overrides" "$android_props"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Install identity comes from the worktree directory name: stable across
|
||||
# branch switches, so one worktree keeps one installed app (and its login
|
||||
# state) no matter how many branches it visits.
|
||||
worktree_name="$(basename "$repo_root")"
|
||||
|
||||
# Display label is context only: the branch name, or a short SHA when the
|
||||
# worktree is detached. Sanitized to [A-Za-z0-9._-] so no valid Git ref can
|
||||
# break Android string resources or xcconfig values.
|
||||
branch=$(git -C "$repo_root" rev-parse --abbrev-ref HEAD)
|
||||
if [[ "$branch" == "HEAD" ]]; then
|
||||
label_raw=$(git -C "$repo_root" rev-parse --short HEAD)
|
||||
else
|
||||
label_raw="${branch##*/}"
|
||||
fi
|
||||
label=$(printf '%s' "$label_raw" | sed -e 's/[^A-Za-z0-9._-]/-/g' -e 's/--*/-/g' -e 's/^-//' -e 's/-$//')
|
||||
[[ -n "$label" ]] || label="worktree"
|
||||
|
||||
# iOS bundle-identifier slug: lowercase, non-alphanumerics collapsed to
|
||||
# hyphens (hyphens are valid in bundle identifiers).
|
||||
ios_slug=$(printf '%s' "$worktree_name" | tr '[:upper:]' '[:lower:]' | sed -e 's/[^a-z0-9]/-/g' -e 's/--*/-/g' -e 's/^-//' -e 's/-$//')
|
||||
[[ -n "$ios_slug" ]] || ios_slug="worktree"
|
||||
|
||||
# Android applicationId segments must match [a-z][a-z0-9_]*: swap hyphens for
|
||||
# underscores and prefix a letter when the slug starts with a digit.
|
||||
android_slug="${ios_slug//-/_}"
|
||||
case "$android_slug" in
|
||||
[0-9]*) android_slug="w_$android_slug" ;;
|
||||
esac
|
||||
|
||||
ios_bundle_id="com.buzz.buzzMobile.${ios_slug}"
|
||||
android_suffix=".${android_slug}"
|
||||
|
||||
cat > "$ios_overrides" <<XCCONFIG
|
||||
// Generated by scripts/mobile-worktree-overrides.sh — gitignored, do not edit.
|
||||
// Applies to Debug builds only (included from Flutter/Debug.xcconfig before
|
||||
// AppOverrides.xcconfig, so a developer's app-specific overrides win).
|
||||
BUNDLE_IDENTIFIER = ${ios_bundle_id}
|
||||
APP_DISPLAY_NAME = Buzz (${label})
|
||||
XCCONFIG
|
||||
|
||||
cat > "$android_props" <<PROPERTIES
|
||||
# Generated by scripts/mobile-worktree-overrides.sh — gitignored, do not edit.
|
||||
# Applies to the debug build type only (read by android/app/build.gradle.kts).
|
||||
label=${label}
|
||||
applicationIdSuffix=${android_suffix}
|
||||
PROPERTIES
|
||||
|
||||
# The printed identifiers are the generated defaults; on iOS a developer's
|
||||
# AppOverrides.xcconfig may override them per variable.
|
||||
echo "📱 Worktree ${worktree_name}: label \"${label}\" (iOS default ${ios_bundle_id}, Android suffix ${android_suffix})"
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "nostr-tools-test",
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"type": "commonjs",
|
||||
"dependencies": {
|
||||
"nostr-tools": "^2.23.3",
|
||||
"ws": "^8.19.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
if [[ $# -lt 2 ]]; then
|
||||
echo "Usage: $0 <pr-number> <png-dir> [comment-body-file]" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
PR="$1"
|
||||
PNG_DIR="$2"
|
||||
BODY_FILE="${3:-}"
|
||||
|
||||
if ! [[ "$PR" =~ ^[0-9]+$ ]]; then
|
||||
echo "error: PR number must be a positive integer" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
GH_USER=$(gh api user --jq .login)
|
||||
BRANCH="agent-screenshots/${GH_USER}"
|
||||
REPO="block/buzz"
|
||||
|
||||
mapfile -t PNGS < <(find "$PNG_DIR" -maxdepth 1 -name "*.png" -type f | sort)
|
||||
if [[ ${#PNGS[@]} -eq 0 ]]; then
|
||||
echo "error: no PNGs found in $PNG_DIR" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
EXISTING_ENTRIES=""
|
||||
if git fetch origin "refs/heads/${BRANCH}:refs/remotes/origin/${BRANCH}" 2>/dev/null; then
|
||||
EXISTING_ENTRIES=$(git ls-tree "origin/${BRANCH}" | grep -v $'\t'"\"\\{0,1\\}pr-${PR}--" || true)
|
||||
fi
|
||||
|
||||
NEW_ENTRIES=""
|
||||
TREE_PATHS=()
|
||||
for PNG in "${PNGS[@]}"; do
|
||||
FILENAME=$(basename "$PNG")
|
||||
if ! [[ "$FILENAME" =~ ^[a-zA-Z0-9_.-]+$ ]]; then
|
||||
echo "error: invalid PNG filename (must be alphanumeric, dots, hyphens, underscores): $FILENAME" >&2
|
||||
exit 1
|
||||
fi
|
||||
BLOB=$(git hash-object -w "$PNG")
|
||||
TREE_PATH="pr-${PR}--${FILENAME}"
|
||||
NEW_ENTRIES+="$(printf '100644 blob %s\t%s' "$BLOB" "$TREE_PATH")"$'\n'
|
||||
TREE_PATHS+=("$TREE_PATH")
|
||||
done
|
||||
|
||||
COMBINED=$(printf '%s\n' "$EXISTING_ENTRIES" "$NEW_ENTRIES" | grep -v '^$')
|
||||
TREE=$(echo "$COMBINED" | git mktree)
|
||||
|
||||
PARENT_ARGS=()
|
||||
if git rev-parse "origin/${BRANCH}" >/dev/null 2>&1; then
|
||||
PARENT_ARGS=(-p "origin/${BRANCH}")
|
||||
fi
|
||||
COMMIT=$(git commit-tree "$TREE" "${PARENT_ARGS[@]}" -m "screenshots: PR #${PR}")
|
||||
git push --force-with-lease origin "${COMMIT}:refs/heads/${BRANCH}"
|
||||
|
||||
RAW_BASE="https://raw.githubusercontent.com/${REPO}/${COMMIT}"
|
||||
|
||||
declare -A IMAGE_URL_MAP
|
||||
IMAGE_URLS=()
|
||||
for i in "${!PNGS[@]}"; do
|
||||
ORIG_NAME="$(basename "${PNGS[$i]}" .png)"
|
||||
URL="${RAW_BASE}/${TREE_PATHS[$i]}"
|
||||
IMAGE_URLS+=("$URL")
|
||||
IMAGE_URL_MAP["$ORIG_NAME"]="$URL"
|
||||
done
|
||||
|
||||
if [[ -n "$BODY_FILE" ]]; then
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
"$SCRIPT_DIR/check-pr-image-urls.sh" "$BODY_FILE"
|
||||
COMMENT_BODY="$(cat "$BODY_FILE")"
|
||||
UNREFERENCED=()
|
||||
for NAME in "${!IMAGE_URL_MAP[@]}"; do
|
||||
URL="${IMAGE_URL_MAP[$NAME]}"
|
||||
PLACEHOLDER="{{${NAME}}}"
|
||||
if [[ "$COMMENT_BODY" == *"$PLACEHOLDER"* ]]; then
|
||||
COMMENT_BODY="${COMMENT_BODY//"$PLACEHOLDER"/}"
|
||||
else
|
||||
UNREFERENCED+=("$NAME")
|
||||
fi
|
||||
done
|
||||
if [[ ${#UNREFERENCED[@]} -gt 0 ]]; then
|
||||
IFS=$'\n' SORTED=($(printf '%s\n' "${UNREFERENCED[@]}" | sort)); unset IFS
|
||||
for NAME in "${SORTED[@]}"; do
|
||||
COMMENT_BODY+=$'\n\n'""
|
||||
done
|
||||
fi
|
||||
else
|
||||
COMMENT_BODY="## Screenshots"$'\n\n'
|
||||
for URL in "${IMAGE_URLS[@]}"; do
|
||||
FILENAME=$(basename "$URL")
|
||||
NAME="${FILENAME%.png}"
|
||||
COMMENT_BODY+=""$'\n\n'
|
||||
done
|
||||
fi
|
||||
|
||||
gh pr comment "$PR" --repo "$REPO" --body "$COMMENT_BODY"
|
||||
echo "Posted ${#PNGS[@]} screenshot(s) to PR #${PR}"
|
||||
@@ -0,0 +1,83 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
version="${1:-}"
|
||||
mode="${2:-publish}"
|
||||
[[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]] || {
|
||||
echo "usage: $0 <semver> [publish|validate-only]" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
remote="${RELEASE_REMOTE:-origin}"
|
||||
git fetch "$remote" refs/heads/main:refs/remotes/origin/main --no-tags
|
||||
git fetch "$remote" '+refs/tags/v*:refs/tags/v*' '+refs/tags/desktop-v*:refs/tags/desktop-v*'
|
||||
base_sha="$(git rev-parse refs/remotes/origin/main)"
|
||||
branch="version-bump/$version"
|
||||
|
||||
remote_branch="refs/heads/$branch"
|
||||
remote_oid=""
|
||||
if remote_oid="$(git ls-remote "$remote" "$remote_branch" | awk '{print $1}')" && [[ -n "$remote_oid" ]]; then
|
||||
git fetch "$remote" "$remote_branch:refs/remotes/origin/$branch"
|
||||
fi
|
||||
|
||||
git checkout -B "$branch" "$base_sha"
|
||||
just bump-desktop-version "$version"
|
||||
scripts/desktop_release.py generate "$version" --base "$base_sha" --repo block/buzz
|
||||
|
||||
git add \
|
||||
.release/desktop-candidate.json \
|
||||
CHANGELOG.md \
|
||||
desktop/package.json \
|
||||
desktop/src-tauri/tauri.conf.json \
|
||||
desktop/src-tauri/Cargo.toml \
|
||||
desktop/src-tauri/Cargo.lock \
|
||||
pnpm-lock.yaml
|
||||
|
||||
agent_name="${RELEASE_AUTOMATION_NAME:-${AGENT_NAME:-Release Automation}}"
|
||||
agent_email="${RELEASE_AUTOMATION_EMAIL:-${AGENT_EMAIL:-release-automation@users.noreply.github.com}}"
|
||||
msg="$(mktemp)"
|
||||
trap 'rm -f "$msg"' EXIT
|
||||
cat >"$msg" <<EOF
|
||||
chore(release): release Buzz Desktop version $version
|
||||
|
||||
Co-authored-by: $agent_name <$agent_email>
|
||||
EOF
|
||||
git -c user.name='Wes' -c user.email='wesbillman@users.noreply.github.com' \
|
||||
commit -s -F "$msg"
|
||||
scripts/desktop_release.py validate --candidate HEAD --version "$version" --repo block/buzz
|
||||
|
||||
candidate_sha="$(git rev-parse HEAD)"
|
||||
previous_tag="$(python3 -c 'import json; print(json.load(open(".release/desktop-candidate.json"))["previous_tag"] or "initial")')"
|
||||
printf 'base_sha=%s\ncandidate_sha=%s\nprevious_tag=%s\ntag=desktop-v%s\n' \
|
||||
"$base_sha" "$candidate_sha" "$previous_tag" "$version"
|
||||
|
||||
if [[ "$mode" == validate-only ]]; then
|
||||
exit 0
|
||||
fi
|
||||
[[ "$mode" == publish ]] || { echo "unknown mode: $mode" >&2; exit 1; }
|
||||
if [[ -n "$remote_oid" ]]; then
|
||||
git push --force-with-lease="$remote_branch:$remote_oid" "$remote" "HEAD:$remote_branch"
|
||||
else
|
||||
git push --force-with-lease="$remote_branch:" "$remote" "HEAD:$remote_branch"
|
||||
fi
|
||||
|
||||
body="$(mktemp)"
|
||||
trap 'rm -f "$msg" "$body"' EXIT
|
||||
cat >"$body" <<EOF
|
||||
## Buzz Desktop release v$version
|
||||
|
||||
- **Frozen main:** \`$base_sha\`
|
||||
- **Reviewed candidate:** \`$candidate_sha\`
|
||||
- **Previous desktop release:** \`$previous_tag\`
|
||||
- **Proposed immutable tag:** \`desktop-v$version\`
|
||||
|
||||
This PR may be **squash merged** after the Desktop Release Candidate check and all protected-branch checks pass. Merging authorizes publication of the exact reviewed candidate; later or unrelated changes on \`main\` cannot alter it.
|
||||
|
||||
The checked-in changelog accounts for every non-merge commit in the release range. The Desktop tag points to the reviewed candidate commit, not the later squash commit. Publication remains bound to that immutable candidate tag.
|
||||
EOF
|
||||
if existing="$(gh pr list --head "$branch" --state open --json number --jq '.[0].number')" && [[ -n "$existing" ]]; then
|
||||
gh pr edit "$existing" --title "chore(release): release Buzz Desktop version $version" --body-file "$body"
|
||||
else
|
||||
gh pr create --base main --head "$branch" \
|
||||
--title "chore(release): release Buzz Desktop version $version" --body-file "$body"
|
||||
fi
|
||||
@@ -0,0 +1,80 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
fail() {
|
||||
echo "Error: $*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
# shellcheck source=scripts/release-rulesets.sh
|
||||
source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/release-rulesets.sh"
|
||||
|
||||
[[ "$#" -eq 3 ]] || fail "usage: $0 X.Y.Z N COMMIT_SHA"
|
||||
version="$1"
|
||||
candidate_number="$2"
|
||||
target_sha="$3"
|
||||
repo="${GITHUB_REPOSITORY:-}"
|
||||
|
||||
[[ "$repo" == "block/buzz" ]] || fail "candidate publishing is restricted to block/buzz"
|
||||
[[ "$version" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]] || \
|
||||
fail "'$version' is not a mobile release version (expected X.Y.Z)"
|
||||
[[ "$candidate_number" =~ ^[1-9][0-9]*$ ]] || \
|
||||
fail "'$candidate_number' is not a candidate number (expected N >= 1)"
|
||||
[[ "$target_sha" =~ ^[0-9a-f]{40}$ ]] || fail "'$target_sha' is not a full commit SHA"
|
||||
command -v gh >/dev/null 2>&1 || fail "gh is required"
|
||||
require_release_tag_ruleset || exit 1
|
||||
|
||||
main_sha="$(gh api "repos/$repo/git/ref/heads/main" --jq .object.sha)" || \
|
||||
fail "could not resolve $repo main"
|
||||
[[ "$main_sha" == "$target_sha" ]] || \
|
||||
fail "$repo main moved from requested commit $target_sha to $main_sha"
|
||||
commit_sha="$(gh api "repos/$repo/commits/$target_sha" --jq .sha)" || \
|
||||
fail "$target_sha is not a commit in $repo"
|
||||
[[ "$commit_sha" == "$target_sha" ]] || \
|
||||
fail "GitHub resolved requested commit $target_sha as $commit_sha"
|
||||
|
||||
refs="$(
|
||||
gh api --paginate "repos/$repo/git/matching-refs/tags/mobile-v${version}-rc." --jq '.[].ref'
|
||||
)" || fail "could not list existing candidates for $version"
|
||||
next=1
|
||||
while IFS= read -r ref; do
|
||||
[[ -n "$ref" ]] || continue
|
||||
[[ "$ref" =~ ^refs/tags/mobile-v${version//./\.}-rc\.([1-9][0-9]*)$ ]] || continue
|
||||
number="${BASH_REMATCH[1]}"
|
||||
(( number >= next )) && next=$((number + 1))
|
||||
done <<< "$refs"
|
||||
[[ "$candidate_number" -eq "$next" ]] || \
|
||||
fail "candidate sequence changed; expected rc.$candidate_number but next is rc.$next"
|
||||
|
||||
tag="mobile-v${version}-rc.${candidate_number}"
|
||||
message="Buzz Mobile $version release candidate $candidate_number"
|
||||
tag_object_sha="$(
|
||||
gh api --method POST "repos/$repo/git/tags" \
|
||||
-f tag="$tag" \
|
||||
-f message="$message" \
|
||||
-f object="$target_sha" \
|
||||
-f type=commit \
|
||||
--jq .sha
|
||||
)" || fail "could not create annotated tag object for $tag"
|
||||
[[ "$tag_object_sha" =~ ^[0-9a-f]{40}$ ]] || fail "GitHub returned an invalid tag object SHA"
|
||||
|
||||
gh api --method POST "repos/$repo/git/refs" \
|
||||
-f ref="refs/tags/$tag" \
|
||||
-f sha="$tag_object_sha" \
|
||||
--silent || fail "could not publish $tag"
|
||||
|
||||
published_type="$(gh api "repos/$repo/git/ref/tags/$tag" --jq .object.type)" || \
|
||||
fail "could not verify published tag $tag"
|
||||
published_object="$(gh api "repos/$repo/git/ref/tags/$tag" --jq .object.sha)" || \
|
||||
fail "could not verify published tag $tag"
|
||||
[[ "$published_type" == "tag" && "$published_object" == "$tag_object_sha" ]] || \
|
||||
fail "$tag does not reference the expected annotated tag object"
|
||||
|
||||
direct_type="$(gh api "repos/$repo/git/tags/$tag_object_sha" --jq .object.type)" || \
|
||||
fail "could not verify annotated tag object $tag_object_sha"
|
||||
direct_sha="$(gh api "repos/$repo/git/tags/$tag_object_sha" --jq .object.sha)" || \
|
||||
fail "could not verify annotated tag object $tag_object_sha"
|
||||
[[ "$direct_type" == "commit" && "$direct_sha" == "$target_sha" ]] || \
|
||||
fail "$tag does not point directly to requested commit $target_sha"
|
||||
|
||||
printf 'Published %s at %s through buzz-release-bot.\n' "$tag" "$target_sha"
|
||||
@@ -0,0 +1,54 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
readonly RELEASE_TAG_RULESET_ID=14378754
|
||||
|
||||
fail_release_ruleset() {
|
||||
echo "Error: $*" >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
require_canonical_repository() {
|
||||
local origin_url
|
||||
|
||||
origin_url="$(git config --get remote.origin.url 2>/dev/null)" || \
|
||||
fail_release_ruleset "origin is required and must point to block/buzz" || return 1
|
||||
case "$origin_url" in
|
||||
git@github.com:block/buzz.git|ssh://git@github.com/block/buzz.git|https://github.com/block/buzz.git|https://github.com/block/buzz)
|
||||
;;
|
||||
*)
|
||||
fail_release_ruleset "origin must point to canonical block/buzz, not '$origin_url'" || return 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
require_release_tag_ruleset() {
|
||||
local ruleset_endpoint state can_bypass rule_types includes excludes
|
||||
|
||||
command -v gh >/dev/null 2>&1 || fail_release_ruleset "gh is required" || return 1
|
||||
ruleset_endpoint="repos/block/buzz/rulesets/$RELEASE_TAG_RULESET_ID"
|
||||
|
||||
state="$(gh api "$ruleset_endpoint" --jq .enforcement)" || \
|
||||
fail_release_ruleset "could not verify Release tag ruleset $RELEASE_TAG_RULESET_ID" || return 1
|
||||
[[ "$state" == "active" ]] || \
|
||||
fail_release_ruleset "Release tag ruleset $RELEASE_TAG_RULESET_ID is '$state'" || return 1
|
||||
|
||||
can_bypass="$(gh api "$ruleset_endpoint" --jq .current_user_can_bypass)" || \
|
||||
fail_release_ruleset "could not verify the release App's tag-ruleset bypass" || return 1
|
||||
[[ "$can_bypass" == "always" ]] || \
|
||||
fail_release_ruleset "release App cannot always bypass Release tag ruleset $RELEASE_TAG_RULESET_ID (reported '$can_bypass')" || return 1
|
||||
|
||||
rule_types="$(gh api "$ruleset_endpoint" --jq '[.rules[].type] | sort | join(",")')" || \
|
||||
fail_release_ruleset "could not verify Release tag ruleset $RELEASE_TAG_RULESET_ID rules" || return 1
|
||||
[[ "$rule_types" == "creation,deletion,non_fast_forward,update" ]] || \
|
||||
fail_release_ruleset "Release tag ruleset $RELEASE_TAG_RULESET_ID has unexpected rules: '$rule_types'" || return 1
|
||||
|
||||
includes="$(gh api "$ruleset_endpoint" --jq '[.conditions.ref_name.include[]] | sort | join(",")')" || \
|
||||
fail_release_ruleset "could not verify Release tag ruleset $RELEASE_TAG_RULESET_ID scope" || return 1
|
||||
[[ ",$includes," == *",refs/tags/mobile-v*,"* ]] || \
|
||||
fail_release_ruleset "Release tag ruleset $RELEASE_TAG_RULESET_ID does not include refs/tags/mobile-v*" || return 1
|
||||
|
||||
excludes="$(gh api "$ruleset_endpoint" --jq '[.conditions.ref_name.exclude[]] | sort | join(",")')" || \
|
||||
fail_release_ruleset "could not verify Release tag ruleset $RELEASE_TAG_RULESET_ID exclusions" || return 1
|
||||
[[ -z "$excludes" ]] || \
|
||||
fail_release_ruleset "Release tag ruleset $RELEASE_TAG_RULESET_ID has unexpected exclusions: '$excludes'" || return 1
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
# REST check runs do not expose per-attempt creation time. The endpoint is
|
||||
# intentionally queried with filter=latest; select the highest immutable run ID
|
||||
# for the trusted producer and require that returned attempt to have completed
|
||||
# successfully by merge. Any ordinary post-merge rerun therefore fails closed
|
||||
# and needs operator inspection. DCO alone has a bounded five-minute exception.
|
||||
[
|
||||
.[].check_runs[]
|
||||
| select(.name == $name and .app.id == $integration_id)
|
||||
]
|
||||
| sort_by(.id)
|
||||
| last
|
||||
| select((.completed_at // null) != null)
|
||||
| select(
|
||||
(.completed_at | fromdateiso8601)
|
||||
<= (
|
||||
($merged_at | fromdateiso8601)
|
||||
+ (if $name == "DCO Check" then 300 else 0 end)
|
||||
)
|
||||
)
|
||||
| .status == "completed"
|
||||
and (
|
||||
.conclusion == "success"
|
||||
or .conclusion == "skipped"
|
||||
or .conclusion == "neutral"
|
||||
)
|
||||
@@ -0,0 +1,69 @@
|
||||
#!/usr/bin/env bash
|
||||
# Remove desktop state owned by development bundle identifiers only.
|
||||
# Production state (`xyz.block.buzz.app`, `~/.buzz`, and `buzz-desktop`) is
|
||||
# deliberately outside every deletion pattern in this script.
|
||||
set -euo pipefail
|
||||
|
||||
log() { printf '[desktop-dev-reset] %s\n' "$*"; }
|
||||
|
||||
remove_path() {
|
||||
local path="$1"
|
||||
if [[ -e "$path" || -L "$path" ]]; then
|
||||
log "Removing $path"
|
||||
rm -rf -- "$path"
|
||||
fi
|
||||
}
|
||||
|
||||
remove_bundle_state() {
|
||||
local base="$1"
|
||||
local suffix="${2:-}"
|
||||
local prefix path
|
||||
|
||||
[[ -d "$base" ]] || return 0
|
||||
shopt -s nullglob
|
||||
for prefix in xyz.block.buzz.app.dev xyz.block.sprout.app.dev; do
|
||||
# Match the canonical dev identifier and dot-delimited worktree variants.
|
||||
# Do not use `${prefix}*`: that could match a non-dev prefix collision.
|
||||
remove_path "$base/${prefix}${suffix}"
|
||||
for path in "$base/${prefix}."*"${suffix}"; do
|
||||
remove_path "$path"
|
||||
done
|
||||
done
|
||||
shopt -u nullglob
|
||||
}
|
||||
|
||||
case "$(uname -s)" in
|
||||
Darwin)
|
||||
remove_bundle_state "$HOME/Library/Application Support"
|
||||
remove_bundle_state "$HOME/Library/Caches"
|
||||
remove_bundle_state "$HOME/Library/WebKit"
|
||||
remove_bundle_state "$HOME/Library/HTTPStorages"
|
||||
remove_bundle_state "$HOME/Library/Saved Application State" ".savedState"
|
||||
remove_bundle_state "$HOME/Library/Preferences" ".plist"
|
||||
|
||||
# SecretStore keeps all dev identity and agent keys in this dev-only item.
|
||||
# Delete every matching item in case an older build used multiple accounts.
|
||||
if command -v security >/dev/null 2>&1; then
|
||||
while security delete-generic-password -s buzz-desktop-dev >/dev/null 2>&1; do :; done
|
||||
while security delete-generic-password -s sprout-desktop-dev >/dev/null 2>&1; do :; done
|
||||
fi
|
||||
;;
|
||||
Linux)
|
||||
remove_bundle_state "${XDG_DATA_HOME:-$HOME/.local/share}"
|
||||
remove_bundle_state "${XDG_CONFIG_HOME:-$HOME/.config}"
|
||||
remove_bundle_state "${XDG_CACHE_HOME:-$HOME/.cache}"
|
||||
;;
|
||||
*)
|
||||
log "Desktop bundle cleanup is not implemented for $(uname -s); continuing"
|
||||
;;
|
||||
esac
|
||||
|
||||
remove_path "$HOME/.buzz-dev"
|
||||
remove_path "$HOME/.sprout-dev"
|
||||
|
||||
# A fresh dev nest must not re-import the installed app's ~/.buzz contents on
|
||||
# its next boot. The sentinel is the same one used by migrate_dev_nest().
|
||||
mkdir -p "$HOME/.buzz-dev"
|
||||
: > "$HOME/.buzz-dev/.dev-nest-migrated"
|
||||
|
||||
log "Development desktop state removed; production Buzz state was not touched"
|
||||
@@ -0,0 +1,48 @@
|
||||
#!/usr/bin/env bash
|
||||
# Reset only one standalone desktop development instance.
|
||||
set -euo pipefail
|
||||
|
||||
instance_id="${1:-}"
|
||||
keyring_service="${2:-}"
|
||||
|
||||
if [[ "$instance_id" != "xyz.block.buzz.app.dev" && "$instance_id" != xyz.block.buzz.app.dev.* ]]; then
|
||||
echo "reset-desktop-standalone-state: refusing non-dev bundle identifier: $instance_id" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ "$keyring_service" != "buzz-desktop-dev" && "$keyring_service" != buzz-desktop-dev.* ]]; then
|
||||
echo "reset-desktop-standalone-state: refusing non-dev keyring service: $keyring_service" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
remove_path() {
|
||||
local path="$1"
|
||||
if [[ -e "$path" || -L "$path" ]]; then
|
||||
echo "Removing $path"
|
||||
rm -rf -- "$path"
|
||||
fi
|
||||
}
|
||||
|
||||
case "${BUZZ_TEST_PLATFORM:-$(uname -s)}" in
|
||||
Darwin)
|
||||
remove_path "$HOME/Library/Application Support/$instance_id"
|
||||
remove_path "$HOME/Library/Caches/$instance_id"
|
||||
remove_path "$HOME/Library/WebKit/$instance_id"
|
||||
remove_path "$HOME/Library/HTTPStorages/$instance_id"
|
||||
remove_path "$HOME/Library/Saved Application State/$instance_id.savedState"
|
||||
remove_path "$HOME/Library/Preferences/$instance_id.plist"
|
||||
if command -v security >/dev/null 2>&1; then
|
||||
while security delete-generic-password -s "$keyring_service" >/dev/null 2>&1; do :; done
|
||||
fi
|
||||
;;
|
||||
Linux)
|
||||
remove_path "${XDG_DATA_HOME:-$HOME/.local/share}/$instance_id"
|
||||
remove_path "${XDG_CONFIG_HOME:-$HOME/.config}/$instance_id"
|
||||
remove_path "${XDG_CACHE_HOME:-$HOME/.cache}/$instance_id"
|
||||
;;
|
||||
*)
|
||||
echo "reset-desktop-standalone-state: unsupported platform" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
echo "Standalone state removed for $instance_id; relay and database data were not touched"
|
||||
@@ -0,0 +1,185 @@
|
||||
#!/usr/bin/env bash
|
||||
# =============================================================================
|
||||
# run-tests.sh — Run Buzz test suite
|
||||
# =============================================================================
|
||||
# Usage:
|
||||
# ./scripts/run-tests.sh # run all tests (default)
|
||||
# ./scripts/run-tests.sh unit # unit tests only (no infra needed)
|
||||
# ./scripts/run-tests.sh integration # integration tests only
|
||||
# ./scripts/run-tests.sh all # explicit all
|
||||
# =============================================================================
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
||||
MODE="${1:-all}"
|
||||
|
||||
# Colors
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
CYAN='\033[0;36m'
|
||||
NC='\033[0m'
|
||||
|
||||
log() { echo -e "${BLUE}[run-tests]${NC} $*"; }
|
||||
success(){ echo -e "${GREEN}[run-tests]${NC} $*"; }
|
||||
warn() { echo -e "${YELLOW}[run-tests]${NC} $*"; }
|
||||
error() { echo -e "${RED}[run-tests]${NC} $*" >&2; }
|
||||
section(){ echo -e "\n${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"; echo -e "${CYAN} $*${NC}"; echo -e "${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"; }
|
||||
|
||||
cd "${REPO_ROOT}"
|
||||
|
||||
# ---- Load .env if present ---------------------------------------------------
|
||||
|
||||
if [[ -f ".env" ]]; then
|
||||
log "Loading .env..."
|
||||
set -o allexport
|
||||
# shellcheck disable=SC1091
|
||||
source .env
|
||||
set +o allexport
|
||||
else
|
||||
# Use defaults matching docker-compose.yml
|
||||
export DATABASE_URL="postgres://buzz:buzz_dev@localhost:5432/buzz" # sadscan:disable np.postgres.1
|
||||
export PGHOST=localhost
|
||||
export PGPORT=5432
|
||||
export PGUSER=buzz
|
||||
export PGPASSWORD=buzz_dev
|
||||
export PGDATABASE=buzz
|
||||
export REDIS_URL="redis://localhost:6379"
|
||||
fi
|
||||
|
||||
# ---- Track results ----------------------------------------------------------
|
||||
|
||||
declare -a PASSED=()
|
||||
declare -a FAILED=()
|
||||
|
||||
run_test_step() {
|
||||
local name="$1"
|
||||
shift
|
||||
log "Running: ${name}"
|
||||
if "$@"; then
|
||||
success "${name} passed"
|
||||
PASSED+=("${name}")
|
||||
else
|
||||
error "${name} FAILED"
|
||||
FAILED+=("${name}")
|
||||
fi
|
||||
}
|
||||
|
||||
# ---- Check / start infra (for integration tests) ----------------------------
|
||||
|
||||
ensure_infra() {
|
||||
"${REPO_ROOT}/bin/just" _ensure-migrations
|
||||
}
|
||||
|
||||
# ---- Unit tests (no infra needed) -------------------------------------------
|
||||
|
||||
run_unit_tests() {
|
||||
section "Unit Tests (no infra required)"
|
||||
|
||||
run_test_step "buzz-core tests" \
|
||||
cargo test -p buzz-core --lib -- --nocapture
|
||||
|
||||
run_test_step "buzz-auth unit tests" \
|
||||
cargo test -p buzz-auth --lib -- --nocapture
|
||||
|
||||
run_test_step "buzz-voice tests" \
|
||||
cargo test -p buzz-voice --lib -- --nocapture
|
||||
|
||||
run_test_step "buzz-cli tests" \
|
||||
cargo test -p buzz-cli -- --nocapture
|
||||
|
||||
# buzz-db migrator/lint unit tests (no infra): guard the embedded-migrator
|
||||
# invariant (exactly the consolidated 0001; cutover/backfill stays an operator
|
||||
# script, not startup state) and the tenant-scoping lints. The Postgres-backed
|
||||
# buzz-db tests are #[ignore]d; nothing here (or in integration mode below,
|
||||
# which runs `cargo test -p buzz-db` without --ignored) runs them — they need a
|
||||
# separate isolated-DB gate, so --lib keeps this step infra-free.
|
||||
run_test_step "buzz-db unit tests" \
|
||||
cargo test -p buzz-db --lib -- --nocapture
|
||||
|
||||
# Multi-tenant conformance gate: independent replay checker + golden
|
||||
# fixtures (buzz-conformance). Pure in-process trace replay, no infra.
|
||||
run_test_step "buzz-conformance tests" \
|
||||
cargo test -p buzz-conformance -- --nocapture
|
||||
|
||||
run_test_step "buzz-push-gateway tests" \
|
||||
cargo test -p buzz-push-gateway -- --nocapture
|
||||
|
||||
# Kubernetes backend provider: pure decision layers driven by a fake
|
||||
# substrate, no cluster. Mirrors the nextest path in `just test-unit` —
|
||||
# the two lists must stay in step or the fallback silently covers less.
|
||||
run_test_step "buzz-backend-kubernetes tests" \
|
||||
cargo test -p buzz-backend-kubernetes -- --nocapture
|
||||
}
|
||||
|
||||
# ---- DB / integration tests (infra required) --------------------------------
|
||||
|
||||
run_integration_tests() {
|
||||
section "Integration Tests (requires running services)"
|
||||
|
||||
ensure_infra
|
||||
|
||||
run_test_step "buzz-db tests" \
|
||||
cargo test -p buzz-db -- --nocapture
|
||||
|
||||
if find crates/buzz-auth/tests -maxdepth 1 -name '*.rs' -print -quit 2>/dev/null | grep -q .; then
|
||||
run_test_step "buzz-auth integration tests" \
|
||||
cargo test -p buzz-auth --test '*' -- --nocapture
|
||||
else
|
||||
run_test_step "buzz-auth (no integration tests found)" true
|
||||
fi
|
||||
|
||||
run_test_step "workspace integration tests" \
|
||||
cargo test --test '*' -- --nocapture 2>/dev/null || \
|
||||
run_test_step "workspace integration tests (none found)" true
|
||||
}
|
||||
|
||||
# ---- Main -------------------------------------------------------------------
|
||||
|
||||
START_TIME=$(date +%s)
|
||||
|
||||
case "${MODE}" in
|
||||
unit)
|
||||
run_unit_tests
|
||||
;;
|
||||
integration)
|
||||
run_integration_tests
|
||||
;;
|
||||
all|*)
|
||||
run_unit_tests
|
||||
run_integration_tests
|
||||
;;
|
||||
esac
|
||||
|
||||
END_TIME=$(date +%s)
|
||||
ELAPSED=$((END_TIME - START_TIME))
|
||||
|
||||
# ---- Summary ----------------------------------------------------------------
|
||||
|
||||
section "Test Summary"
|
||||
echo ""
|
||||
echo -e " Duration: ${ELAPSED}s"
|
||||
echo ""
|
||||
|
||||
if [[ ${#PASSED[@]} -gt 0 ]]; then
|
||||
echo -e " ${GREEN}Passed (${#PASSED[@]}):${NC}"
|
||||
for t in "${PASSED[@]}"; do
|
||||
echo -e " ${GREEN}pass${NC} ${t}"
|
||||
done
|
||||
fi
|
||||
|
||||
if [[ ${#FAILED[@]} -gt 0 ]]; then
|
||||
echo ""
|
||||
echo -e " ${RED}Failed (${#FAILED[@]}):${NC}"
|
||||
for t in "${FAILED[@]}"; do
|
||||
echo -e " ${RED}fail${NC} ${t}"
|
||||
done
|
||||
echo ""
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo ""
|
||||
success "All tests passed!"
|
||||
exit 0
|
||||
@@ -0,0 +1,194 @@
|
||||
#!/usr/bin/env bash
|
||||
# Seed deterministic moderation reports and product feedback for local dashboard review.
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
||||
cd "${REPO_ROOT}"
|
||||
|
||||
if [[ -f ".env" ]]; then
|
||||
set -o allexport
|
||||
# shellcheck disable=SC1091
|
||||
source .env
|
||||
set +o allexport
|
||||
fi
|
||||
|
||||
export PGHOST="${PGHOST:-localhost}"
|
||||
export PGPORT="${PGPORT:-5432}"
|
||||
export PGUSER="${PGUSER:-buzz}"
|
||||
export PGPASSWORD="${PGPASSWORD:-buzz_dev}"
|
||||
export PGDATABASE="${PGDATABASE:-buzz}"
|
||||
|
||||
if command -v psql >/dev/null 2>&1; then
|
||||
run_psql() {
|
||||
PGPASSWORD="${PGPASSWORD}" psql -h "${PGHOST}" -p "${PGPORT}" \
|
||||
-U "${PGUSER}" -d "${PGDATABASE}" "$@"
|
||||
}
|
||||
elif docker exec buzz-postgres psql --version >/dev/null 2>&1; then
|
||||
run_psql() {
|
||||
docker exec -i -e PGPASSWORD="${PGPASSWORD}" buzz-postgres \
|
||||
psql -U "${PGUSER}" -d "${PGDATABASE}" "$@"
|
||||
}
|
||||
else
|
||||
echo "error: neither psql nor buzz-postgres docker psql is available" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
community_id="$(run_psql -At -v ON_ERROR_STOP=1 -c "
|
||||
SELECT id
|
||||
FROM communities
|
||||
WHERE lower(host) IN ('localhost:3000', 'localhost', '127.0.0.1:3000', '127.0.0.1')
|
||||
ORDER BY CASE lower(host)
|
||||
WHEN 'localhost:3000' THEN 1
|
||||
WHEN 'localhost' THEN 2
|
||||
WHEN '127.0.0.1:3000' THEN 3
|
||||
ELSE 4
|
||||
END
|
||||
LIMIT 1
|
||||
")"
|
||||
if [[ -z "${community_id}" ]]; then
|
||||
echo "error: local community is missing; run just setup first" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
fixture_hash() {
|
||||
if command -v sha256sum >/dev/null 2>&1; then
|
||||
sha256sum "$1" | awk '{print $1}'
|
||||
else
|
||||
shasum -a 256 "$1" | awk '{print $1}'
|
||||
fi
|
||||
}
|
||||
|
||||
fixture_size() {
|
||||
wc -c < "$1" | awk '{print $1}'
|
||||
}
|
||||
|
||||
upload_fixture() {
|
||||
local path="$1" hash="$2" extension="$3" mime="$4" dimensions="$5"
|
||||
local size sidecar
|
||||
size="$(fixture_size "${path}")"
|
||||
sidecar="$(printf '{"dim":"%s","blurhash":"","thumb_url":"","ext":"%s","mime_type":"%s","size":%s,"uploaded_at":0}' \
|
||||
"${dimensions}" "${extension}" "${mime}" "${size}")"
|
||||
docker exec -i buzz-minio mc pipe --quiet --attr "Content-Type=${mime}" \
|
||||
"local/${BUZZ_S3_BUCKET:-buzz-media}/${hash}.${extension}" < "${path}"
|
||||
printf '%s' "${sidecar}" | docker exec -i buzz-minio mc pipe --quiet \
|
||||
--attr "Content-Type=application/json" \
|
||||
"local/${BUZZ_S3_BUCKET:-buzz-media}/_meta/${community_id}/${hash}.json"
|
||||
}
|
||||
|
||||
fixture_dir="$(mktemp -d "${TMPDIR:-/tmp}/buzz-admin-feedback.XXXXXX")"
|
||||
search_image="${REPO_ROOT}/docs/assets/screenshots/media-comments.png"
|
||||
workspace_image="${REPO_ROOT}/docs/assets/screenshots/channel-thread.png"
|
||||
quality_image="${REPO_ROOT}/docs/assets/screenshots/channel-agents.png"
|
||||
composer_diagnostics="${fixture_dir}/composer-diagnostics.txt"
|
||||
workspace_diagnostics="${fixture_dir}/workspace-diagnostics.txt"
|
||||
trap 'rm -f "${composer_diagnostics}" "${workspace_diagnostics}"; rmdir "${fixture_dir}"' EXIT
|
||||
|
||||
printf '%s\n' "buzz feedback diagnostics" "area: composer" \
|
||||
"event: resumed_from_sleep" "result: composer_unresponsive" > "${composer_diagnostics}"
|
||||
printf '%s\n' "buzz feedback diagnostics" "area: workspace-switching" \
|
||||
"from: design" "to: engineering" \
|
||||
"result: previous_sidebar_visible_for_one_frame" > "${workspace_diagnostics}"
|
||||
|
||||
search_image_hash="$(fixture_hash "${search_image}")"
|
||||
workspace_image_hash="$(fixture_hash "${workspace_image}")"
|
||||
quality_image_hash="$(fixture_hash "${quality_image}")"
|
||||
composer_diagnostics_hash="$(fixture_hash "${composer_diagnostics}")"
|
||||
workspace_diagnostics_hash="$(fixture_hash "${workspace_diagnostics}")"
|
||||
|
||||
if ! docker exec buzz-minio mc alias set local http://localhost:9000 \
|
||||
"${BUZZ_S3_ACCESS_KEY:-buzz_dev}" "${BUZZ_S3_SECRET_KEY:-buzz_dev_secret}" >/dev/null; then
|
||||
echo "error: local MinIO is unavailable; run just setup first" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
upload_fixture "${search_image}" "${search_image_hash}" png image/png 2000x1172
|
||||
upload_fixture "${workspace_image}" "${workspace_image_hash}" png image/png 2000x1172
|
||||
upload_fixture "${quality_image}" "${quality_image_hash}" png image/png 2000x1172
|
||||
upload_fixture "${composer_diagnostics}" "${composer_diagnostics_hash}" txt text/plain ""
|
||||
upload_fixture "${workspace_diagnostics}" "${workspace_diagnostics_hash}" txt text/plain ""
|
||||
|
||||
read -r -d '' sql <<'SQL' || true
|
||||
DO $$
|
||||
DECLARE
|
||||
local_community_id UUID;
|
||||
BEGIN
|
||||
SELECT id INTO local_community_id
|
||||
FROM communities
|
||||
WHERE lower(host) IN ('localhost:3000', 'localhost', '127.0.0.1:3000', '127.0.0.1')
|
||||
ORDER BY CASE lower(host)
|
||||
WHEN 'localhost:3000' THEN 1
|
||||
WHEN 'localhost' THEN 2
|
||||
WHEN '127.0.0.1:3000' THEN 3
|
||||
ELSE 4
|
||||
END
|
||||
LIMIT 1;
|
||||
|
||||
IF local_community_id IS NULL THEN
|
||||
RAISE EXCEPTION 'local community is missing; run just setup first';
|
||||
END IF;
|
||||
|
||||
INSERT INTO moderation_reports (
|
||||
community_id, id, report_event_id, reporter_pubkey, target_kind,
|
||||
target_event_id, target_pubkey, target_blob_sha256, report_type, note,
|
||||
status, resolved_by, resolved_at, created_at
|
||||
) VALUES
|
||||
(local_community_id, 'a11d0000-0000-4000-8000-000000000001', decode(repeat('01', 32), 'hex'), decode(repeat('11', 32), 'hex'), 'event', decode(repeat('21', 32), 'hex'), NULL, NULL, 'spam', 'Repeated unsolicited promotion across several channels.', 'open', NULL, NULL, now() - interval '8 minutes'),
|
||||
(local_community_id, 'a11d0000-0000-4000-8000-000000000002', decode(repeat('02', 32), 'hex'), decode(repeat('12', 32), 'hex'), 'pubkey', NULL, decode(repeat('22', 32), 'hex'), NULL, 'impersonation', 'Profile appears to impersonate a community organizer.', 'open', NULL, NULL, now() - interval '25 minutes'),
|
||||
(local_community_id, 'a11d0000-0000-4000-8000-000000000003', decode(repeat('03', 32), 'hex'), decode(repeat('13', 32), 'hex'), 'blob', NULL, NULL, decode(repeat('23', 32), 'hex'), 'malware', 'Attachment was flagged after download.', 'open', NULL, NULL, now() - interval '50 minutes'),
|
||||
(local_community_id, 'a11d0000-0000-4000-8000-000000000004', decode(repeat('04', 32), 'hex'), decode(repeat('14', 32), 'hex'), 'event', decode(repeat('24', 32), 'hex'), NULL, NULL, 'illegal', 'Contains material that may require legal review.', 'open', NULL, NULL, now() - interval '2 hours'),
|
||||
(local_community_id, 'a11d0000-0000-4000-8000-000000000005', decode(repeat('05', 32), 'hex'), decode(repeat('15', 32), 'hex'), 'blob', NULL, NULL, decode(repeat('25', 32), 'hex'), 'nudity', NULL, 'open', NULL, NULL, now() - interval '5 hours'),
|
||||
(local_community_id, 'a11d0000-0000-4000-8000-000000000006', decode(repeat('06', 32), 'hex'), decode(repeat('16', 32), 'hex'), 'pubkey', NULL, decode(repeat('26', 32), 'hex'), NULL, 'profanity', 'Repeated abusive replies from this account.', 'open', NULL, NULL, now() - interval '12 hours'),
|
||||
(local_community_id, 'a11d0000-0000-4000-8000-000000000007', decode(repeat('07', 32), 'hex'), decode(repeat('17', 32), 'hex'), 'event', decode(repeat('27', 32), 'hex'), NULL, NULL, 'other', 'Does not fit a standard report category.', 'open', NULL, NULL, now() - interval '1 day'),
|
||||
(local_community_id, 'a11d0000-0000-4000-8000-000000000008', decode(repeat('08', 32), 'hex'), decode(repeat('18', 32), 'hex'), 'pubkey', NULL, decode(repeat('28', 32), 'hex'), NULL, 'impersonation', 'Escalated while ownership is verified.', 'escalated', decode(repeat('38', 32), 'hex'), now() - interval '1 hour', now() - interval '2 days'),
|
||||
(local_community_id, 'a11d0000-0000-4000-8000-000000000009', decode(repeat('09', 32), 'hex'), decode(repeat('19', 32), 'hex'), 'blob', NULL, NULL, decode(repeat('29', 32), 'hex'), 'malware', 'Resolved after the attachment was removed.', 'resolved', decode(repeat('39', 32), 'hex'), now() - interval '1 day', now() - interval '3 days'),
|
||||
(local_community_id, 'a11d0000-0000-4000-8000-000000000010', decode(repeat('0a', 32), 'hex'), decode(repeat('1a', 32), 'hex'), 'event', decode(repeat('2a', 32), 'hex'), NULL, NULL, 'other', 'Dismissed after reviewing the surrounding thread.', 'dismissed', decode(repeat('3a', 32), 'hex'), now() - interval '3 days', now() - interval '4 days')
|
||||
ON CONFLICT (community_id, report_event_id) DO UPDATE SET
|
||||
reporter_pubkey = EXCLUDED.reporter_pubkey,
|
||||
target_kind = EXCLUDED.target_kind,
|
||||
target_event_id = EXCLUDED.target_event_id,
|
||||
target_pubkey = EXCLUDED.target_pubkey,
|
||||
target_blob_sha256 = EXCLUDED.target_blob_sha256,
|
||||
report_type = EXCLUDED.report_type,
|
||||
note = EXCLUDED.note,
|
||||
status = EXCLUDED.status,
|
||||
resolved_by = EXCLUDED.resolved_by,
|
||||
resolved_at = EXCLUDED.resolved_at,
|
||||
created_at = EXCLUDED.created_at;
|
||||
|
||||
INSERT INTO product_feedback (
|
||||
id, community_id, event_id, submitter_pubkey, category, body, tags,
|
||||
event_created_at, received_at
|
||||
) VALUES
|
||||
('feed0000-0000-4000-8000-000000000001', local_community_id, decode(repeat('41', 32), 'hex'), decode(repeat('51', 32), 'hex'), 'bug', 'Unread counts return after reopening the desktop app.', '[["category", "bug"]]', now() - interval '20 minutes', now() - interval '19 minutes'),
|
||||
('feed0000-0000-4000-8000-000000000002', local_community_id, decode(repeat('42', 32), 'hex'), decode(repeat('52', 32), 'hex'), 'needs-work', E'Search needs clearer empty-state guidance.\n', '[["category", "needs-work"], ["imeta", "url http://localhost:3000/media/__SEARCH_IMAGE_HASH__.png", "m image/png", "x __SEARCH_IMAGE_HASH__", "size __SEARCH_IMAGE_SIZE__", "dim 2000x1172", "filename search-empty-state.png"]]', now() - interval '5 hours', now() - interval '5 hours'),
|
||||
('feed0000-0000-4000-8000-000000000003', local_community_id, decode(repeat('43', 32), 'hex'), decode(repeat('53', 32), 'hex'), 'praise', 'The new channel switcher feels immediate.', '[["category", "praise"]]', now() - interval '1 day', now() - interval '1 day'),
|
||||
('feed0000-0000-4000-8000-000000000004', local_community_id, decode(repeat('44', 32), 'hex'), decode(repeat('54', 32), 'hex'), 'bug', E'The composer froze after waking my laptop. Diagnostics attached.\n[feedback-diagnostics.txt](http://localhost:3000/media/__COMPOSER_DIAGNOSTICS_HASH__.txt)', '[["category", "bug"], ["imeta", "url http://localhost:3000/media/__COMPOSER_DIAGNOSTICS_HASH__.txt", "m text/plain", "x __COMPOSER_DIAGNOSTICS_HASH__", "size __COMPOSER_DIAGNOSTICS_SIZE__", "filename feedback-diagnostics.txt"]]', now() - interval '2 days', now() - interval '2 days'),
|
||||
('feed0000-0000-4000-8000-000000000005', local_community_id, decode(repeat('45', 32), 'hex'), decode(repeat('55', 32), 'hex'), NULL, 'General feedback without a selected category or any attachments.', '[]', now() - interval '3 days', now() - interval '3 days'),
|
||||
('feed0000-0000-4000-8000-000000000006', local_community_id, decode(repeat('46', 32), 'hex'), decode(repeat('56', 32), 'hex'), 'needs-work', E'The sidebar briefly renders the previous workspace after switching. Screenshot and diagnostics attached.\n\n[feedback-diagnostics.txt](http://localhost:3000/media/__WORKSPACE_DIAGNOSTICS_HASH__.txt)', '[["category", "needs-work"], ["imeta", "url http://localhost:3000/media/__WORKSPACE_IMAGE_HASH__.png", "m image/png", "x __WORKSPACE_IMAGE_HASH__", "size __WORKSPACE_IMAGE_SIZE__", "dim 2000x1172", "filename workspace-flash.png"], ["imeta", "url http://localhost:3000/media/__WORKSPACE_DIAGNOSTICS_HASH__.txt", "m text/plain", "x __WORKSPACE_DIAGNOSTICS_HASH__", "size __WORKSPACE_DIAGNOSTICS_SIZE__", "filename feedback-diagnostics.txt"]]', now() - interval '5 days', now() - interval '5 days'),
|
||||
('feed0000-0000-4000-8000-000000000007', local_community_id, decode(repeat('47', 32), 'hex'), decode(repeat('57', 32), 'hex'), 'praise', E'Calls have been much more reliable this week. Attaching the quality graph that made the improvement obvious.\n', '[["category", "praise"], ["imeta", "url http://localhost:3000/media/__QUALITY_IMAGE_HASH__.png", "m image/png", "x __QUALITY_IMAGE_HASH__", "size __QUALITY_IMAGE_SIZE__", "dim 2000x1172", "filename huddle-quality.png"]]', now() - interval '8 days', now() - interval '8 days')
|
||||
ON CONFLICT (event_id) DO UPDATE SET
|
||||
community_id = EXCLUDED.community_id,
|
||||
submitter_pubkey = EXCLUDED.submitter_pubkey,
|
||||
category = EXCLUDED.category,
|
||||
body = EXCLUDED.body,
|
||||
tags = EXCLUDED.tags,
|
||||
event_created_at = EXCLUDED.event_created_at,
|
||||
received_at = EXCLUDED.received_at;
|
||||
END $$;
|
||||
SQL
|
||||
|
||||
sql="${sql//__SEARCH_IMAGE_HASH__/${search_image_hash}}"
|
||||
sql="${sql//__WORKSPACE_IMAGE_HASH__/${workspace_image_hash}}"
|
||||
sql="${sql//__QUALITY_IMAGE_HASH__/${quality_image_hash}}"
|
||||
sql="${sql//__COMPOSER_DIAGNOSTICS_HASH__/${composer_diagnostics_hash}}"
|
||||
sql="${sql//__WORKSPACE_DIAGNOSTICS_HASH__/${workspace_diagnostics_hash}}"
|
||||
sql="${sql//__SEARCH_IMAGE_SIZE__/$(fixture_size "${search_image}")}"
|
||||
sql="${sql//__WORKSPACE_IMAGE_SIZE__/$(fixture_size "${workspace_image}")}"
|
||||
sql="${sql//__QUALITY_IMAGE_SIZE__/$(fixture_size "${quality_image}")}"
|
||||
sql="${sql//__COMPOSER_DIAGNOSTICS_SIZE__/$(fixture_size "${composer_diagnostics}")}"
|
||||
sql="${sql//__WORKSPACE_DIAGNOSTICS_SIZE__/$(fixture_size "${workspace_diagnostics}")}"
|
||||
|
||||
run_psql -v ON_ERROR_STOP=1 -c "${sql}"
|
||||
|
||||
echo "Seeded 10 moderation reports, 7 feedback entries, and 5 attachments for the local admin dashboard."
|
||||
@@ -0,0 +1,98 @@
|
||||
#!/usr/bin/env bash
|
||||
# Seed local dev host -> community rows for row-zero host binding.
|
||||
#
|
||||
# The relay intentionally fails closed when the request Host header is not in
|
||||
# `communities`. Local dev uses loopback hosts, so bootstrap must create those
|
||||
# rows after migrations before desktop/Tauri HTTP bridge calls can succeed.
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
||||
cd "${REPO_ROOT}"
|
||||
|
||||
if [[ -f ".env" ]]; then
|
||||
set -o allexport
|
||||
# shellcheck disable=SC1091
|
||||
source .env
|
||||
set +o allexport
|
||||
fi
|
||||
|
||||
export PGHOST="${PGHOST:-localhost}"
|
||||
export PGPORT="${PGPORT:-5432}"
|
||||
export PGUSER="${PGUSER:-buzz}"
|
||||
export PGPASSWORD="${PGPASSWORD:-buzz_dev}"
|
||||
export PGDATABASE="${PGDATABASE:-buzz}"
|
||||
export RELAY_URL="${RELAY_URL:-ws://localhost:3000}"
|
||||
|
||||
hosts_sql=$(python3 - <<'PY'
|
||||
import os
|
||||
from urllib.parse import urlparse
|
||||
|
||||
relay_url = os.environ.get("RELAY_URL", "ws://localhost:3000")
|
||||
parsed = urlparse(relay_url)
|
||||
|
||||
host = (parsed.hostname or "").rstrip(".").lower()
|
||||
port = parsed.port
|
||||
scheme = parsed.scheme.lower()
|
||||
|
||||
def authority(host, port, scheme):
|
||||
if not host:
|
||||
return ""
|
||||
display_host = f"[{host}]" if ":" in host and not host.startswith("[") else host
|
||||
default_port = (scheme == "ws" and port == 80) or (scheme == "wss" and port == 443)
|
||||
if port and not default_port:
|
||||
return f"{display_host}:{port}"
|
||||
return display_host
|
||||
|
||||
primary = authority(host, port, scheme)
|
||||
hosts = []
|
||||
if primary:
|
||||
hosts.append(primary)
|
||||
|
||||
# Local desktop/dev tooling has historically used both localhost and 127.0.0.1,
|
||||
# and some HTTP clients can omit the default/non-default port in Host handling.
|
||||
# Under row-zero host binding these are distinct hosts, so seed loopback aliases
|
||||
# for local dev to avoid a fail-closed 404 when one side uses an alternate
|
||||
# authority. Non-loopback deployments seed only RELAY_URL's authority.
|
||||
if host in {"localhost", "127.0.0.1"}:
|
||||
hosts.extend(["localhost", "127.0.0.1"])
|
||||
if port:
|
||||
hosts.extend([f"localhost:{port}", f"127.0.0.1:{port}"])
|
||||
|
||||
seen = []
|
||||
for h in hosts:
|
||||
if h and h not in seen:
|
||||
seen.append(h)
|
||||
|
||||
if not seen:
|
||||
raise SystemExit("could not derive a host from RELAY_URL")
|
||||
|
||||
lines = []
|
||||
for h in seen:
|
||||
escaped = h.replace(chr(39), chr(39) * 2)
|
||||
lines.append(f" ('{escaped}')")
|
||||
print(",\n".join(lines))
|
||||
PY
|
||||
)
|
||||
|
||||
sql="
|
||||
INSERT INTO communities (host)
|
||||
SELECT host
|
||||
FROM (VALUES
|
||||
${hosts_sql}
|
||||
) AS v(host)
|
||||
ON CONFLICT (lower(host)) DO NOTHING;
|
||||
"
|
||||
|
||||
if command -v psql >/dev/null 2>&1; then
|
||||
PGPASSWORD="${PGPASSWORD}" psql -h "${PGHOST}" -p "${PGPORT}" -U "${PGUSER}" -d "${PGDATABASE}" -v ON_ERROR_STOP=1 -c "${sql}"
|
||||
elif docker exec buzz-postgres psql --version >/dev/null 2>&1; then
|
||||
docker exec -i -e PGPASSWORD="${PGPASSWORD}" buzz-postgres \
|
||||
psql -U "${PGUSER}" -d "${PGDATABASE}" -v ON_ERROR_STOP=1 -c "${sql}"
|
||||
else
|
||||
echo "error: neither psql nor buzz-postgres docker psql is available" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Seeded local dev community host(s):"
|
||||
echo "${hosts_sql}" | sed -E "s/^ +\('(.+)'\),?$/ - \1/"
|
||||
@@ -0,0 +1,132 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
DB_HOST="${BUZZ_DB_HOST:-127.0.0.1}"
|
||||
DB_PORT="${BUZZ_DB_PORT:-5432}"
|
||||
DB_USER="${BUZZ_DB_USER:-buzz}"
|
||||
DB_PASS="${BUZZ_DB_PASS:-buzz_dev}"
|
||||
DB_NAME="${BUZZ_DB_NAME:-buzz}"
|
||||
DB_DOCKER_CONTAINER="${BUZZ_DB_DOCKER_CONTAINER:-buzz-postgres}"
|
||||
|
||||
SYSTEM_PUBKEY="0000000000000000000000000000000000000000000000000000000000000000"
|
||||
ALICE_PUBKEY="953d3363262e86b770419834c53d2446409db6d918a57f8f339d495d54ab001f"
|
||||
BOB_PUBKEY="bb22a5299220cad76ffd46190ccbeede8ab5dc260faa28b6e5a2cb31b9aff260"
|
||||
CHARLIE_PUBKEY="554cef57437abac34522ac2c9f0490d685b72c80478cf9f7ed6f9570ee8624ea"
|
||||
TYLER_PUBKEY="e5ebc6cdb579be112e336cc319b5989b4bb6af11786ea90dbe52b5f08d741b34"
|
||||
AGENT_PUBKEY="db0b028cd36f4d3e36c8300cce87252c1f7fc9495ffecc53f393fcac341ffd36"
|
||||
|
||||
if command -v psql >/dev/null 2>&1; then
|
||||
run_psql() { PGPASSWORD="$DB_PASS" psql -h"$DB_HOST" -p"$DB_PORT" -U"$DB_USER" -d"$DB_NAME" -qtA "$@"; }
|
||||
elif docker exec "$DB_DOCKER_CONTAINER" psql --version >/dev/null 2>&1; then
|
||||
run_psql() {
|
||||
docker exec -e PGPASSWORD="$DB_PASS" "$DB_DOCKER_CONTAINER" \
|
||||
psql -U"$DB_USER" -d"$DB_NAME" -qtA "$@"
|
||||
}
|
||||
else
|
||||
echo "No psql client available. Start docker compose or install postgresql-client." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
run_sql() {
|
||||
run_psql -c "$1"
|
||||
}
|
||||
|
||||
uuid5_hex() {
|
||||
local slug="$1"
|
||||
python3 - "$slug" <<'PYEOF'
|
||||
import sys, uuid
|
||||
# Format as UUID with hyphens for Postgres
|
||||
print(str(uuid.uuid5(uuid.NAMESPACE_DNS, sys.argv[1])))
|
||||
PYEOF
|
||||
}
|
||||
|
||||
echo "Checking database connection..."
|
||||
run_sql "SELECT 1" >/dev/null
|
||||
|
||||
# Multi-tenant: every scoped row carries community_id. The relay resolves the
|
||||
# tenant per-connection from the durable communities host map (WHERE host = $1
|
||||
# against the *normalized* host). normalize_host() keeps non-default ports, so
|
||||
# the host must be 'localhost:3000' verbatim to match RELAY_URL=ws://localhost:3000.
|
||||
# Insert the host if another setup step has not already done so, then resolve
|
||||
# its real id. `just setup` seeds local host aliases with generated ids, so a
|
||||
# fixture-owned id would point channel rows at a community that does not exist.
|
||||
# Host must match the relay's normalized bind host verbatim (non-default ports
|
||||
# are kept by normalize_host). Overridable so an isolated relay on an alternate
|
||||
# port can seed the same channels/members against its own tenant.
|
||||
COMMUNITY_HOST="${BUZZ_COMMUNITY_HOST:-localhost:3000}"
|
||||
COMMUNITY_HOST_SQL="${COMMUNITY_HOST//\'/\'\'}"
|
||||
|
||||
run_sql "
|
||||
INSERT INTO communities (host)
|
||||
VALUES ('${COMMUNITY_HOST_SQL}')
|
||||
ON CONFLICT (lower(host)) DO NOTHING
|
||||
;
|
||||
"
|
||||
COMMUNITY_ID="$(run_sql "
|
||||
SELECT id
|
||||
FROM communities
|
||||
WHERE lower(host) = lower('${COMMUNITY_HOST_SQL}')
|
||||
LIMIT 1
|
||||
;
|
||||
")"
|
||||
if [[ ! "${COMMUNITY_ID}" =~ ^[0-9a-fA-F-]{36}$ ]]; then
|
||||
echo "Could not resolve community id for host ${COMMUNITY_HOST}." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
UUID_GENERAL=$(uuid5_hex "buzz.channel.general")
|
||||
UUID_RANDOM=$(uuid5_hex "buzz.channel.random")
|
||||
UUID_ENGINEERING=$(uuid5_hex "buzz.channel.engineering")
|
||||
UUID_AGENTS=$(uuid5_hex "buzz.channel.agents")
|
||||
UUID_WATERCOOLER=$(uuid5_hex "buzz.channel.watercooler")
|
||||
UUID_ANNOUNCEMENTS=$(uuid5_hex "buzz.channel.announcements")
|
||||
UUID_DM_ALICE_TYLER=$(uuid5_hex "buzz.channel.dm.alice-tyler")
|
||||
UUID_DM_BOB_TYLER=$(uuid5_hex "buzz.channel.dm.bob-tyler")
|
||||
UUID_DM_BOB_CHARLIE_TYLER=$(uuid5_hex "buzz.channel.dm.bob-charlie-tyler")
|
||||
|
||||
run_sql "
|
||||
INSERT INTO channels
|
||||
(community_id, id, name, channel_type, visibility, description, created_by, topic_required)
|
||||
VALUES
|
||||
('${COMMUNITY_ID}', '${UUID_GENERAL}', 'general', 'stream', 'open', 'General discussion for everyone', decode('${SYSTEM_PUBKEY}','hex'), false),
|
||||
('${COMMUNITY_ID}', '${UUID_RANDOM}', 'random', 'stream', 'open', 'Off-topic, fun stuff', decode('${SYSTEM_PUBKEY}','hex'), false),
|
||||
('${COMMUNITY_ID}', '${UUID_ENGINEERING}', 'engineering', 'stream', 'open', 'Engineering discussions', decode('${SYSTEM_PUBKEY}','hex'), false),
|
||||
('${COMMUNITY_ID}', '${UUID_AGENTS}', 'agents', 'stream', 'open', 'AI agent testing and collaboration', decode('${SYSTEM_PUBKEY}','hex'), false),
|
||||
('${COMMUNITY_ID}', '${UUID_WATERCOOLER}', 'watercooler', 'forum', 'open', 'Casual forum for async discussions', decode('${SYSTEM_PUBKEY}','hex'), true),
|
||||
('${COMMUNITY_ID}', '${UUID_ANNOUNCEMENTS}', 'announcements', 'forum', 'open', 'Company announcements', decode('${SYSTEM_PUBKEY}','hex'), true),
|
||||
('${COMMUNITY_ID}', '${UUID_DM_ALICE_TYLER}', 'alice-tyler', 'dm', 'private', 'DM between alice and tyler', decode('${SYSTEM_PUBKEY}','hex'), false),
|
||||
('${COMMUNITY_ID}', '${UUID_DM_BOB_TYLER}', 'bob-tyler', 'dm', 'private', 'DM between bob and tyler', decode('${SYSTEM_PUBKEY}','hex'), false),
|
||||
('${COMMUNITY_ID}', '${UUID_DM_BOB_CHARLIE_TYLER}', 'bob-charlie-tyler', 'dm', 'private', 'Group DM: bob, charlie, tyler', decode('${SYSTEM_PUBKEY}','hex'), false)
|
||||
ON CONFLICT DO NOTHING
|
||||
;
|
||||
"
|
||||
|
||||
run_sql "
|
||||
INSERT INTO channel_members
|
||||
(community_id, channel_id, pubkey, role, invited_by)
|
||||
VALUES
|
||||
('${COMMUNITY_ID}', '${UUID_GENERAL}', decode('${TYLER_PUBKEY}','hex'), 'member', decode('${SYSTEM_PUBKEY}','hex')),
|
||||
('${COMMUNITY_ID}', '${UUID_GENERAL}', decode('${ALICE_PUBKEY}','hex'), 'member', decode('${SYSTEM_PUBKEY}','hex')),
|
||||
('${COMMUNITY_ID}', '${UUID_GENERAL}', decode('${BOB_PUBKEY}','hex'), 'member', decode('${SYSTEM_PUBKEY}','hex')),
|
||||
('${COMMUNITY_ID}', '${UUID_RANDOM}', decode('${TYLER_PUBKEY}','hex'), 'member', decode('${SYSTEM_PUBKEY}','hex')),
|
||||
('${COMMUNITY_ID}', '${UUID_ENGINEERING}', decode('${TYLER_PUBKEY}','hex'), 'member', decode('${SYSTEM_PUBKEY}','hex')),
|
||||
('${COMMUNITY_ID}', '${UUID_AGENTS}', decode('${TYLER_PUBKEY}','hex'), 'member', decode('${SYSTEM_PUBKEY}','hex')),
|
||||
('${COMMUNITY_ID}', '${UUID_WATERCOOLER}', decode('${TYLER_PUBKEY}','hex'), 'member', decode('${SYSTEM_PUBKEY}','hex')),
|
||||
('${COMMUNITY_ID}', '${UUID_ANNOUNCEMENTS}', decode('${TYLER_PUBKEY}','hex'), 'guest', decode('${SYSTEM_PUBKEY}','hex')),
|
||||
('${COMMUNITY_ID}', '${UUID_DM_ALICE_TYLER}', decode('${ALICE_PUBKEY}','hex'), 'member', decode('${SYSTEM_PUBKEY}','hex')),
|
||||
('${COMMUNITY_ID}', '${UUID_DM_ALICE_TYLER}', decode('${TYLER_PUBKEY}','hex'), 'member', decode('${SYSTEM_PUBKEY}','hex')),
|
||||
('${COMMUNITY_ID}', '${UUID_DM_BOB_TYLER}', decode('${BOB_PUBKEY}','hex'), 'member', decode('${SYSTEM_PUBKEY}','hex')),
|
||||
('${COMMUNITY_ID}', '${UUID_DM_BOB_TYLER}', decode('${TYLER_PUBKEY}','hex'), 'member', decode('${SYSTEM_PUBKEY}','hex')),
|
||||
('${COMMUNITY_ID}', '${UUID_DM_BOB_CHARLIE_TYLER}', decode('${BOB_PUBKEY}','hex'), 'member', decode('${SYSTEM_PUBKEY}','hex')),
|
||||
('${COMMUNITY_ID}', '${UUID_DM_BOB_CHARLIE_TYLER}', decode('${CHARLIE_PUBKEY}','hex'), 'member', decode('${SYSTEM_PUBKEY}','hex')),
|
||||
('${COMMUNITY_ID}', '${UUID_DM_BOB_CHARLIE_TYLER}', decode('${TYLER_PUBKEY}','hex'), 'member', decode('${SYSTEM_PUBKEY}','hex')),
|
||||
('${COMMUNITY_ID}', '${UUID_GENERAL}', decode('${AGENT_PUBKEY}','hex'), 'bot', decode('${SYSTEM_PUBKEY}','hex')),
|
||||
('${COMMUNITY_ID}', '${UUID_RANDOM}', decode('${AGENT_PUBKEY}','hex'), 'bot', decode('${SYSTEM_PUBKEY}','hex')),
|
||||
('${COMMUNITY_ID}', '${UUID_ENGINEERING}', decode('${AGENT_PUBKEY}','hex'), 'bot', decode('${SYSTEM_PUBKEY}','hex')),
|
||||
('${COMMUNITY_ID}', '${UUID_AGENTS}', decode('${AGENT_PUBKEY}','hex'), 'bot', decode('${SYSTEM_PUBKEY}','hex'))
|
||||
ON CONFLICT DO NOTHING
|
||||
;
|
||||
"
|
||||
|
||||
echo "Desktop e2e data ready."
|
||||
@@ -0,0 +1,16 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
# Match desktop's URL-scoped git credential configuration without installing a
|
||||
# helper globally (which would make it answer for unrelated remotes).
|
||||
if [[ -n "${BUZZ_RELAY_URL:-}" ]]; then
|
||||
relay_http_url="${BUZZ_RELAY_URL/#ws:/http:}"
|
||||
relay_http_url="${relay_http_url/#wss:/https:}"
|
||||
relay_http_url="${relay_http_url%/}"
|
||||
git config --global "credential.${relay_http_url}/git.helper" \
|
||||
/usr/local/bin/git-credential-nostr
|
||||
git config --global "credential.${relay_http_url}/git.useHttpPath" true
|
||||
fi
|
||||
|
||||
# The harness must receive Kubernetes' termination signal directly.
|
||||
exec buzz-acp "$@"
|
||||
@@ -0,0 +1,170 @@
|
||||
#!/usr/bin/env bash
|
||||
# =============================================================================
|
||||
# start-isolated-test-relay.sh — GUI read-model overhaul test harness (Dawn)
|
||||
# =============================================================================
|
||||
# Stands up a FULLY ISOLATED relay for seeding + parity/perf runs, from source
|
||||
# on the current branch. Never touches the shared :3000 team relay or the
|
||||
# default `buzz-*` dev stack. Backing services run under the dedicated
|
||||
# `buzz-harness` Compose project (docker-compose.harness.yml); the relay runs
|
||||
# in the foreground on override ports.
|
||||
#
|
||||
# Topology (reuse this exact tuple for desktop parity runs):
|
||||
# compose project : buzz-harness
|
||||
# postgres : localhost:5471 (db=buzz, user=buzz, pass=buzz_dev)
|
||||
# redis : localhost:6471
|
||||
# minio : localhost:9471 (console 9472)
|
||||
# relay main : localhost:3030 ← BUZZ_E2E_RELAY_URL=http://localhost:3030
|
||||
# relay health : localhost:8088
|
||||
# relay metrics : localhost:9202
|
||||
#
|
||||
# Usage:
|
||||
# ./scripts/start-isolated-test-relay.sh [--profile <cargo-profile>]
|
||||
#
|
||||
# Teardown (safe — scoped to our project only):
|
||||
# docker compose -p buzz-harness -f docker-compose.harness.yml down -v
|
||||
# =============================================================================
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
||||
cd "${REPO_ROOT}"
|
||||
|
||||
CARGO_PROFILE="${CARGO_PROFILE:-ci}"
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--profile) CARGO_PROFILE="$2"; shift 2 ;;
|
||||
*) echo "Unknown option: $1" >&2; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Cargo names the development profile `dev`, but writes its binaries under
|
||||
# target/debug. Accept `debug` as the user-facing spelling too.
|
||||
case "${CARGO_PROFILE}" in
|
||||
dev|debug)
|
||||
CARGO_BUILD_PROFILE="dev"
|
||||
CARGO_TARGET_PROFILE="debug"
|
||||
;;
|
||||
*)
|
||||
CARGO_BUILD_PROFILE="${CARGO_PROFILE}"
|
||||
CARGO_TARGET_PROFILE="${CARGO_PROFILE}"
|
||||
;;
|
||||
esac
|
||||
|
||||
PROJECT="buzz-harness"
|
||||
COMPOSE_FILE="docker-compose.harness.yml"
|
||||
|
||||
# Isolated ports (distinct from :3000 team relay, default dev stack, and Eva's
|
||||
# evaperf :5470/:6470/:9470/:3170 stack).
|
||||
PG_PORT=5471
|
||||
REDIS_PORT=6471
|
||||
MINIO_PORT=9471
|
||||
RELAY_MAIN=3030
|
||||
RELAY_HEALTH=8088
|
||||
RELAY_METRICS=9202
|
||||
COMMUNITY_HOST="localhost:${RELAY_MAIN}"
|
||||
|
||||
BLUE='\033[0;34m'; GREEN='\033[0;32m'; RED='\033[0;31m'; NC='\033[0m'
|
||||
log() { echo -e "${BLUE}[isolated-relay]${NC} $*"; }
|
||||
ok() { echo -e "${GREEN}[isolated-relay]${NC} $*"; }
|
||||
err() { echo -e "${RED}[isolated-relay]${NC} $*" >&2; }
|
||||
|
||||
# ── Backing services (scoped to buzz-harness only) ───────────────────────────
|
||||
log "Bringing up backing services (project=${PROJECT})..."
|
||||
docker compose -p "${PROJECT}" -f "${COMPOSE_FILE}" up -d
|
||||
|
||||
wait_pg() {
|
||||
for _ in $(seq 1 60); do
|
||||
if docker compose -p "${PROJECT}" -f "${COMPOSE_FILE}" exec -T postgres \
|
||||
pg_isready -U buzz >/dev/null 2>&1; then
|
||||
ok "Postgres ready"; return 0
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
err "Postgres did not become ready"; return 1
|
||||
}
|
||||
wait_pg
|
||||
|
||||
# ── Schema + partitions ──────────────────────────────────────────────────────
|
||||
export PGPASSWORD=buzz_dev
|
||||
psql_h() { docker compose -p "${PROJECT}" -f "${COMPOSE_FILE}" exec -T postgres \
|
||||
psql -U buzz -d buzz -v ON_ERROR_STOP=1 "$@"; }
|
||||
|
||||
log "Resetting isolated database and applying schema..."
|
||||
# This database belongs only to the buzz-harness Compose project. Reset it on
|
||||
# every launch so stale partitions/events from an earlier proof cannot alter
|
||||
# schema planning or test results.
|
||||
psql_h -c "DROP SCHEMA public CASCADE; CREATE SCHEMA public;"
|
||||
export PGSCHEMA_PLAN_HOST=localhost PGSCHEMA_PLAN_PORT=${PG_PORT}
|
||||
export PGSCHEMA_PLAN_DB=buzz PGSCHEMA_PLAN_USER=buzz PGSCHEMA_PLAN_PASSWORD=buzz_dev
|
||||
export PGHOST=localhost PGPORT=${PG_PORT} PGUSER=buzz PGDATABASE=buzz
|
||||
./bin/pgschema apply --file schema/schema.sql --auto-approve
|
||||
psql_h < scripts/attach-schema-partitions.sql
|
||||
ok "Schema applied"
|
||||
|
||||
# ── Deployment community + channels + members ────────────────────────────────
|
||||
# setup-desktop-test-data.sh is the single writer of the dev community row and
|
||||
# the channel/member seed. It keys everything off a fixed COMMUNITY_ID and an
|
||||
# overridable host — point that host at OUR relay so the tenant binding matches,
|
||||
# and point its DB env at OUR isolated postgres. (psql is on PATH, so it uses
|
||||
# BUZZ_DB_HOST/PORT rather than the shared `buzz-postgres` container.)
|
||||
log "Seeding community (host=${COMMUNITY_HOST}), channels, and members..."
|
||||
BUZZ_COMMUNITY_HOST="${COMMUNITY_HOST}" \
|
||||
BUZZ_DB_HOST=localhost BUZZ_DB_PORT=${PG_PORT} BUZZ_DB_USER=buzz \
|
||||
BUZZ_DB_PASS=buzz_dev BUZZ_DB_NAME=buzz \
|
||||
BUZZ_DB_DOCKER_CONTAINER="${PROJECT}-postgres-1" \
|
||||
./scripts/setup-desktop-test-data.sh
|
||||
ok "Community + channels + members seeded"
|
||||
|
||||
# ── Build relay from source (current branch) ─────────────────────────────────
|
||||
# The repo pins Rust via rust-toolchain.toml (1.95.0). Outside the hermit env a
|
||||
# stray Homebrew `cargo` (1.89) shadows the pin and fails on sqlx's MSRV, so
|
||||
# prefer the rustup shim, which honors the pin.
|
||||
if [[ -x "${HOME}/.cargo/bin/cargo" ]]; then
|
||||
export PATH="${HOME}/.cargo/bin:${PATH}"
|
||||
fi
|
||||
log "Building relay (profile=${CARGO_BUILD_PROFILE}, cargo=$(command -v cargo), $(cargo --version))..."
|
||||
cargo build --profile "${CARGO_BUILD_PROFILE}" -p buzz-relay
|
||||
ok "Relay built"
|
||||
|
||||
# ── Run relay (detached tmux session) ────────────────────────────────────────
|
||||
# Run inside tmux, NOT the foreground: this script is invoked from ephemeral
|
||||
# shells whose process group is reaped on return, which SIGTERMs a foreground
|
||||
# relay ~seconds after startup. tmux fully daemonizes the session so the relay
|
||||
# survives (same pattern the perf stack uses). Logs to ${RELAY_LOG}.
|
||||
RELAY_LOG="${RELAY_LOG:-/tmp/dawn-relay-run.log}"
|
||||
TMUX_SESSION="${TMUX_SESSION:-dawn-relay}"
|
||||
tmux kill-session -t "${TMUX_SESSION}" 2>/dev/null || true
|
||||
if command -v lsof >/dev/null 2>&1 && lsof -nP -iTCP:"${RELAY_MAIN}" -sTCP:LISTEN >/dev/null 2>&1; then
|
||||
err "Port ${RELAY_MAIN} is already in use; refusing to report a stale relay as this harness."
|
||||
lsof -nP -iTCP:"${RELAY_MAIN}" -sTCP:LISTEN >&2 || true
|
||||
exit 1
|
||||
fi
|
||||
log "Starting relay in tmux session '${TMUX_SESSION}' on :${RELAY_MAIN} (health :${RELAY_HEALTH}, metrics :${RELAY_METRICS})..."
|
||||
tmux new-session -d -s "${TMUX_SESSION}" "cd '${REPO_ROOT}' && env \
|
||||
DATABASE_URL=postgres://buzz:buzz_dev@localhost:${PG_PORT}/buzz \
|
||||
REDIS_URL=redis://localhost:${REDIS_PORT} \
|
||||
RELAY_URL=ws://localhost:${RELAY_MAIN} \
|
||||
BUZZ_BIND_ADDR=0.0.0.0:${RELAY_MAIN} \
|
||||
BUZZ_HEALTH_PORT=${RELAY_HEALTH} \
|
||||
BUZZ_METRICS_PORT=${RELAY_METRICS} \
|
||||
BUZZ_S3_ENDPOINT=http://localhost:${MINIO_PORT} \
|
||||
BUZZ_S3_ACCESS_KEY=buzz_dev \
|
||||
BUZZ_S3_SECRET_KEY=buzz_dev_secret \
|
||||
BUZZ_S3_BUCKET=buzz-media \
|
||||
BUZZ_REQUIRE_AUTH_TOKEN=false \
|
||||
BUZZ_RECONCILE_CHANNELS=true \
|
||||
'./target/${CARGO_TARGET_PROFILE}/buzz-relay' > '${RELAY_LOG}' 2>&1"
|
||||
|
||||
# Wait for the main port to accept connections.
|
||||
for _ in $(seq 1 30); do
|
||||
if curl -s -o /dev/null "http://localhost:${RELAY_MAIN}/"; then
|
||||
ok "Relay live — BUZZ_E2E_RELAY_URL=http://localhost:${RELAY_MAIN}"
|
||||
ok "Logs: ${RELAY_LOG} Attach: tmux attach -t ${TMUX_SESSION}"
|
||||
ok "Stop relay: tmux kill-session -t ${TMUX_SESSION}"
|
||||
ok "Full teardown: docker compose -p ${PROJECT} -f ${COMPOSE_FILE} down -v"
|
||||
exit 0
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
err "Relay did not come up on :${RELAY_MAIN} within 30s — check ${RELAY_LOG}"
|
||||
exit 1
|
||||
@@ -0,0 +1,200 @@
|
||||
#!/usr/bin/env bash
|
||||
# =============================================================================
|
||||
# start-relay-for-tests.sh — Start the Buzz relay and its backing services
|
||||
# =============================================================================
|
||||
# Shared script for CI jobs that need a running relay. Starts docker compose
|
||||
# services, waits for health, applies the schema, builds the relay, starts it,
|
||||
# and polls readiness.
|
||||
#
|
||||
# Usage:
|
||||
# ./scripts/start-relay-for-tests.sh [--profile <cargo-profile>] [--no-build]
|
||||
#
|
||||
# Options:
|
||||
# --profile <profile> Cargo build profile (default: ci)
|
||||
# --no-build Use existing target/<profile>/ binaries (CI artifact reuse)
|
||||
#
|
||||
# Exports:
|
||||
# RELAY_URL=ws://localhost:3000
|
||||
# =============================================================================
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
||||
|
||||
# ── Defaults ──────────────────────────────────────────────────────────────────
|
||||
|
||||
CARGO_PROFILE="${CARGO_PROFILE:-ci}"
|
||||
SKIP_BUILD=false
|
||||
|
||||
# ── Parse args ────────────────────────────────────────────────────────────────
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--profile)
|
||||
CARGO_PROFILE="$2"
|
||||
shift 2
|
||||
;;
|
||||
--no-build)
|
||||
SKIP_BUILD=true
|
||||
shift
|
||||
;;
|
||||
*)
|
||||
echo "Unknown option: $1" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# ── Colors ────────────────────────────────────────────────────────────────────
|
||||
|
||||
BLUE='\033[0;34m'
|
||||
GREEN='\033[0;32m'
|
||||
RED='\033[0;31m'
|
||||
NC='\033[0m'
|
||||
|
||||
log() { echo -e "${BLUE}[relay-test]${NC} $*"; }
|
||||
ok() { echo -e "${GREEN}[relay-test]${NC} $*"; }
|
||||
err() { echo -e "${RED}[relay-test]${NC} $*" >&2; }
|
||||
|
||||
# ── Start docker compose services ────────────────────────────────────────────
|
||||
|
||||
cd "${REPO_ROOT}"
|
||||
|
||||
log "Starting docker compose services..."
|
||||
docker compose up -d postgres redis minio minio-init
|
||||
|
||||
# ── Wait for services to be healthy ──────────────────────────────────────────
|
||||
|
||||
wait_healthy() {
|
||||
local service="$1"
|
||||
local container="$2"
|
||||
log "Waiting for ${service}..."
|
||||
for attempt in $(seq 1 60); do
|
||||
status=$(docker inspect --format='{{.State.Health.Status}}' "${container}" 2>/dev/null || echo "not_found")
|
||||
if [ "${status}" = "healthy" ]; then
|
||||
ok "${service} is healthy"
|
||||
return 0
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
err "${service} did not become healthy within 120s"
|
||||
docker logs "${container}" || true
|
||||
return 1
|
||||
}
|
||||
|
||||
wait_healthy "Postgres" "buzz-postgres"
|
||||
wait_healthy "Redis" "buzz-redis"
|
||||
wait_healthy "MinIO" "buzz-minio"
|
||||
|
||||
# ── Apply database schema ────────────────────────────────────────────────────
|
||||
|
||||
log "Applying database schema..."
|
||||
export PGHOST=localhost
|
||||
export PGPORT=5432
|
||||
export PGUSER=buzz
|
||||
export PGPASSWORD=buzz_dev
|
||||
export PGDATABASE=buzz
|
||||
|
||||
# Use the already-running docker postgres for desired-state planning instead of
|
||||
# downloading an embedded Postgres from Maven Central (transient-fetch flake source).
|
||||
export PGSCHEMA_PLAN_HOST=localhost
|
||||
export PGSCHEMA_PLAN_PORT=5432
|
||||
export PGSCHEMA_PLAN_DB=buzz
|
||||
export PGSCHEMA_PLAN_USER=buzz
|
||||
export PGSCHEMA_PLAN_PASSWORD=buzz_dev
|
||||
|
||||
./bin/pgschema apply --file schema/schema.sql --auto-approve
|
||||
docker exec -i -e PGPASSWORD="${PGPASSWORD}" buzz-postgres \
|
||||
psql -U "${PGUSER}" -d "${PGDATABASE}" -v ON_ERROR_STOP=1 < scripts/attach-schema-partitions.sql
|
||||
ok "Schema applied"
|
||||
|
||||
# ── Seed the deployment community ────────────────────────────────────────────
|
||||
# Multi-tenant: the relay resolves every connection's tenant from the durable
|
||||
# communities host map (WHERE host = normalize_host($1)). normalize_host keeps
|
||||
# non-default ports, so the host must be 'localhost:3000' verbatim to match
|
||||
# RELAY_URL=ws://localhost:3000. The relay never auto-seeds a community
|
||||
# (ensure_configured_community has no callers) and fails closed on an unmapped
|
||||
# host, so without this row every e2e connection would 404 at host-binding.
|
||||
# The unique index is on lower(host), so ON CONFLICT must target that expression.
|
||||
# psql is not on PATH in the hermit env; postgres runs as the buzz-postgres
|
||||
# docker container, so exec into it (same fallback as setup-desktop-test-data.sh).
|
||||
log "Seeding deployment community (host=localhost:3000)..."
|
||||
if command -v psql >/dev/null 2>&1; then
|
||||
seed_psql() { PGPASSWORD="${PGPASSWORD}" psql -h "${PGHOST}" -p "${PGPORT}" -U "${PGUSER}" -d "${PGDATABASE}" -qtA "$@"; }
|
||||
else
|
||||
seed_psql() { docker exec -e PGPASSWORD="${PGPASSWORD}" buzz-postgres psql -U "${PGUSER}" -d "${PGDATABASE}" -qtA "$@"; }
|
||||
fi
|
||||
seed_psql -c "
|
||||
INSERT INTO communities (id, host)
|
||||
VALUES ('00000000-0000-4000-8000-00000000c0de', 'localhost:3000')
|
||||
ON CONFLICT (lower(host)) DO NOTHING
|
||||
;
|
||||
"
|
||||
ok "Community seeded"
|
||||
|
||||
# ── Build relay ──────────────────────────────────────────────────────────────
|
||||
|
||||
if [[ "${SKIP_BUILD}" == "true" ]]; then
|
||||
for bin in buzz-relay git-credential-nostr; do
|
||||
if [[ ! -x "./target/${CARGO_PROFILE}/${bin}" ]]; then
|
||||
err "--no-build: ./target/${CARGO_PROFILE}/${bin} missing or not executable"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
log "Skipping relay build (--no-build); using existing target/${CARGO_PROFILE}/ binaries"
|
||||
else
|
||||
log "Building relay (profile: ${CARGO_PROFILE})..."
|
||||
cargo build --profile "${CARGO_PROFILE}" -p buzz-relay -p git-credential-nostr
|
||||
ok "Relay built"
|
||||
fi
|
||||
|
||||
# ── Start relay ──────────────────────────────────────────────────────────────
|
||||
|
||||
log "Starting relay..."
|
||||
|
||||
# Optional NIP-43 membership gating: exported by callers that need a
|
||||
# membership-gated relay (e.g. the mesh lifecycle smoke). All three must be
|
||||
# set together — the relay fails fast otherwise.
|
||||
MEMBERSHIP_ENV=()
|
||||
if [[ "${BUZZ_REQUIRE_RELAY_MEMBERSHIP:-}" == "true" ]]; then
|
||||
MEMBERSHIP_ENV+=(
|
||||
BUZZ_REQUIRE_RELAY_MEMBERSHIP=true
|
||||
RELAY_OWNER_PUBKEY="${RELAY_OWNER_PUBKEY:?RELAY_OWNER_PUBKEY required with BUZZ_REQUIRE_RELAY_MEMBERSHIP=true}"
|
||||
BUZZ_RELAY_PRIVATE_KEY="${BUZZ_RELAY_PRIVATE_KEY:?BUZZ_RELAY_PRIVATE_KEY required with BUZZ_REQUIRE_RELAY_MEMBERSHIP=true}"
|
||||
)
|
||||
log "Membership gating enabled (NIP-43)"
|
||||
fi
|
||||
|
||||
nohup env \
|
||||
DATABASE_URL=postgres://buzz:buzz_dev@localhost:5432/buzz \
|
||||
REDIS_URL=redis://localhost:6379 \
|
||||
RELAY_URL=ws://localhost:3000 \
|
||||
BUZZ_BIND_ADDR=0.0.0.0:3000 \
|
||||
BUZZ_REQUIRE_AUTH_TOKEN=false \
|
||||
BUZZ_RECONCILE_CHANNELS=true \
|
||||
BUZZ_GIT_PROBE_WRITERS=8 \
|
||||
${MEMBERSHIP_ENV[@]+"${MEMBERSHIP_ENV[@]}"} \
|
||||
"./target/${CARGO_PROFILE}/buzz-relay" > /tmp/buzz-relay.log 2>&1 &
|
||||
echo $! > /tmp/buzz-relay.pid
|
||||
|
||||
# ── Poll readiness ───────────────────────────────────────────────────────────
|
||||
|
||||
log "Waiting for relay readiness..."
|
||||
for attempt in $(seq 1 60); do
|
||||
if ! kill -0 "$(cat /tmp/buzz-relay.pid)" 2>/dev/null; then
|
||||
err "Relay process died"
|
||||
cat /tmp/buzz-relay.log
|
||||
exit 1
|
||||
fi
|
||||
status_code=$(curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:3000/_readiness || true)
|
||||
if [ "${status_code}" = "200" ]; then
|
||||
ok "Relay is ready at ws://localhost:3000"
|
||||
export RELAY_URL=ws://localhost:3000
|
||||
exit 0
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
err "Relay did not become ready within 60s"
|
||||
cat /tmp/buzz-relay.log
|
||||
exit 1
|
||||
@@ -0,0 +1,43 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
repo_root=$(cd "$(dirname "$0")/.." && pwd)
|
||||
key_script="scripts/desktop-release-cache-key.py"
|
||||
tmp=$(mktemp -d)
|
||||
trap 'rm -rf "$tmp"' EXIT
|
||||
cp -R "$repo_root"/. "$tmp/repo"
|
||||
cd "$tmp/repo"
|
||||
|
||||
args=(--platform Linux --target x86_64-unknown-linux-gnu --features mesh-llm --native-inputs ubuntu-24.04-mold)
|
||||
original=$("$key_script" "${args[@]}")
|
||||
python3 - <<'PY'
|
||||
from pathlib import Path
|
||||
import re
|
||||
|
||||
manifest = Path("desktop/src-tauri/Cargo.toml")
|
||||
manifest_text = manifest.read_text()
|
||||
package = re.search(r'(?ms)^\[package\]\n.*?^version = "([^"]+)"', manifest_text)
|
||||
if package is None:
|
||||
raise SystemExit("desktop package version not found")
|
||||
current_version = package.group(1)
|
||||
manifest.write_text(
|
||||
manifest_text[: package.start(1)] + "9.8.7" + manifest_text[package.end(1) :]
|
||||
)
|
||||
|
||||
lock = Path("desktop/src-tauri/Cargo.lock")
|
||||
text = lock.read_text()
|
||||
start = text.index('name = "buzz-desktop"')
|
||||
version = text.index(f'version = "{current_version}"', start)
|
||||
lock.write_text(
|
||||
text[:version]
|
||||
+ 'version = "9.8.7"'
|
||||
+ text[version + len(f'version = "{current_version}"') :]
|
||||
)
|
||||
PY
|
||||
version_only=$("$key_script" "${args[@]}")
|
||||
[[ "$original" == "$version_only" ]] || { echo "desktop version changed cache key" >&2; exit 1; }
|
||||
printf '\n# dependency input\n' >> crates/buzz-acp/Cargo.toml
|
||||
dependency_changed=$("$key_script" "${args[@]}")
|
||||
[[ "$original" != "$dependency_changed" ]] || { echo "dependency manifest did not change cache key" >&2; exit 1; }
|
||||
[[ "$original" == desktop-rust-release-v1-Linux-x86_64-unknown-linux-gnu-* ]] || { echo "unexpected key: $original" >&2; exit 1; }
|
||||
echo "desktop release cache key contract passed"
|
||||
@@ -0,0 +1,77 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
root=$(cd "$(dirname "$0")/.." && pwd)
|
||||
release="$root/.github/workflows/release.yml"
|
||||
proof="$root/.github/workflows/desktop-release-cache-proof.yml"
|
||||
canaries=(
|
||||
"$root/.github/workflows/signed-macos-canary.yml"
|
||||
"$root/.github/workflows/macos-intel-canary.yml"
|
||||
"$root/.github/workflows/windows-canary.yml"
|
||||
"$root/.github/workflows/linux-canary.yml"
|
||||
)
|
||||
|
||||
if grep -q 'desktop-rust-release-v1\|desktop-release-cache-key' "$release"; then
|
||||
echo "Gate 1 must not alter the release cache path" >&2
|
||||
exit 1
|
||||
fi
|
||||
for workflow in "${canaries[@]}"; do
|
||||
grep -q 'refs/heads/main' "$workflow"
|
||||
grep -q 'desktop-native-toolchain-id.sh' "$workflow"
|
||||
grep -q 'steps.native_toolchain.outputs.id' "$workflow"
|
||||
grep -q 'actions/cache/restore@' "$workflow"
|
||||
grep -q 'actions/cache/save@' "$workflow"
|
||||
grep -q 'steps.rust_cache.outputs.cache-hit' "$workflow"
|
||||
grep -q '!desktop/src-tauri/target/\*\*/release/bundle' "$workflow"
|
||||
if grep -q 'restore-keys:.*desktop-rust\|Swatinem/rust-cache' "$workflow"; then
|
||||
echo "release Cargo cache must use split actions with no fallback: $workflow" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
# GitHub expressions must enter cache-key steps through env, never by direct
|
||||
# interpolation into generated shell scripts. This blocks shell injection if a
|
||||
# matrix or upstream output ever becomes attacker-controlled.
|
||||
python3 - "$proof" "${canaries[@]}" <<'PY'
|
||||
import pathlib
|
||||
import re
|
||||
import sys
|
||||
|
||||
for filename in sys.argv[1:]:
|
||||
text = pathlib.Path(filename).read_text()
|
||||
steps = re.findall(
|
||||
r"(?ms)^ - name: Compute exact release cache key\n(.*?)(?=^ - (?:name:|uses:)|\Z)",
|
||||
text,
|
||||
)
|
||||
if not steps:
|
||||
raise SystemExit(f"cache-key step missing: {filename}")
|
||||
for step in steps:
|
||||
run = re.search(r"(?ms)^ run: \|\n(.*?)(?=^ \S|\Z)", step)
|
||||
if not run:
|
||||
raise SystemExit(f"cache-key run block missing: {filename}")
|
||||
if "${{" in run.group(1):
|
||||
raise SystemExit(f"GitHub expression interpolated into cache-key shell: {filename}")
|
||||
if "NATIVE_TOOLCHAIN_ID: ${{ steps.native_toolchain.outputs.id }}" not in step:
|
||||
raise SystemExit(f"native toolchain output not passed through env: {filename}")
|
||||
PY
|
||||
|
||||
# Producer/proof coverage must match all four release targets and features.
|
||||
for target in aarch64-apple-darwin x86_64-apple-darwin x86_64-unknown-linux-gnu x86_64-pc-windows-msvc; do
|
||||
grep -q -- "$target" "$proof" || { echo "proof missing $target" >&2; exit 1; }
|
||||
done
|
||||
grep -q -- '--features mesh-llm' "$proof"
|
||||
grep -q -- '--features default' "$proof"
|
||||
[[ $(grep -c 'actions/cache/save@' "$proof") -eq 0 ]]
|
||||
[[ $(grep -c 'Require exact cache hit' "$proof") -eq 3 ]]
|
||||
grep -q 'refs/tags/cache-proof-' "$proof"
|
||||
|
||||
# Linux producer must match release's default linker, not the CI-only mold path.
|
||||
if grep -q 'setup-mold\|ubuntu-24.04-mold' "$root/.github/workflows/linux-canary.yml"; then
|
||||
echo "Linux cache producer diverges from the release linker" >&2
|
||||
exit 1
|
||||
fi
|
||||
if grep -q 'setup-mold' "$release"; then
|
||||
echo "release linker changed; re-review cache equivalence" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "desktop release cache workflow contract passed"
|
||||
@@ -0,0 +1,194 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
repo_root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
|
||||
tmp=$(mktemp -d)
|
||||
trap 'rm -rf "$tmp"' EXIT
|
||||
cp "$repo_root/scripts/desktop_release.py" "$tmp/desktop_release.py"
|
||||
git -C "$tmp" init -q
|
||||
git -C "$tmp" config user.name test
|
||||
git -C "$tmp" config user.email test@example.com
|
||||
mkdir -p "$tmp/scripts" "$tmp/desktop/src-tauri" "$tmp/crates/buzz-core" "$tmp/.release"
|
||||
mv "$tmp/desktop_release.py" "$tmp/scripts/desktop_release.py"
|
||||
printf '{"version":"1.0.0"}\n' > "$tmp/desktop/package.json"
|
||||
printf '{"version":"1.0.0"}\n' > "$tmp/desktop/src-tauri/tauri.conf.json"
|
||||
printf '[package]\nversion = "1.0.0"\n' > "$tmp/desktop/src-tauri/Cargo.toml"
|
||||
printf '# Changelog\n' > "$tmp/CHANGELOG.md"
|
||||
echo root > "$tmp/ROOT.md"
|
||||
git -C "$tmp" add .
|
||||
git -C "$tmp" commit -qm 'feat: root content'
|
||||
prior_base=$(git -C "$tmp" rev-parse HEAD)
|
||||
|
||||
# The prior immutable candidate lives on side history after its squash merge.
|
||||
git -C "$tmp" checkout -qb prior-candidate
|
||||
echo prior > "$tmp/desktop/feature"
|
||||
cat > "$tmp/.release/desktop-candidate.json" <<JSON
|
||||
{"schema":1,"version":"1.0.0","base_sha":"$prior_base","previous_tag":null,"tag":"desktop-v1.0.0","commit_count":1}
|
||||
JSON
|
||||
git -C "$tmp" add .
|
||||
git -C "$tmp" commit -qm 'chore(release): release Buzz Desktop version 1.0.0'
|
||||
prior_candidate=$(git -C "$tmp" rev-parse HEAD)
|
||||
git -C "$tmp" -c tag.gpgSign=false tag desktop-v1.0.0
|
||||
|
||||
git -C "$tmp" checkout -q -
|
||||
echo before-squash > "$tmp/POLICY.md"
|
||||
git -C "$tmp" add POLICY.md
|
||||
git -C "$tmp" commit -qm 'chore(release): unrelated hostile subject'
|
||||
unrelated_before=$(git -C "$tmp" rev-parse HEAD)
|
||||
echo squash > "$tmp/PRIOR_RELEASE.md"
|
||||
git -C "$tmp" add PRIOR_RELEASE.md
|
||||
git -C "$tmp" commit -qm 'edited prior release subject'
|
||||
prior_merge=$(git -C "$tmp" rev-parse HEAD)
|
||||
echo after-squash >> "$tmp/desktop/feature"
|
||||
git -C "$tmp" add desktop/feature
|
||||
git -C "$tmp" commit -qm 'fix: desktop fix after prior release'
|
||||
unrelated_after=$(git -C "$tmp" rev-parse HEAD)
|
||||
base=$(git -C "$tmp" rev-parse HEAD)
|
||||
|
||||
mock_bin=$(mktemp -d)
|
||||
cat > "$mock_bin/gh" <<GH
|
||||
#!/usr/bin/env bash
|
||||
[[ "\$1" == api && "\$2" == "repos/block/buzz/commits/$prior_candidate/pulls" ]] || exit 90
|
||||
cat <<JSON
|
||||
[{"merged_at":"2026-01-01T00:00:00Z","merge_commit_sha":"$prior_merge","head":{"sha":"$prior_candidate"}}]
|
||||
JSON
|
||||
GH
|
||||
chmod +x "$mock_bin/gh"
|
||||
(
|
||||
cd "$tmp"
|
||||
PATH="$mock_bin:$PATH" scripts/desktop_release.py generate 1.0.1 --base "$base" --repo block/buzz
|
||||
python3 - <<'PY'
|
||||
import json
|
||||
for path in ('desktop/package.json', 'desktop/src-tauri/tauri.conf.json'):
|
||||
data=json.load(open(path)); data['version']='1.0.1'; open(path,'w').write(json.dumps(data)+'\n')
|
||||
open('desktop/src-tauri/Cargo.toml','w').write('[package]\nversion = "1.0.1"\n')
|
||||
PY
|
||||
git add .
|
||||
git -c user.name=Wes -c user.email=wesbillman@users.noreply.github.com commit -q -s -m 'chore(release): release Buzz Desktop version 1.0.1' -m 'Co-authored-by: Test Automation <test@example.com>'
|
||||
PATH="$mock_bin:$PATH" scripts/desktop_release.py validate --version 1.0.1 --repo block/buzz
|
||||
grep -Fq "$unrelated_before" CHANGELOG.md
|
||||
grep -Fq "$unrelated_after" CHANGELOG.md
|
||||
! grep -Fq "$prior_merge" CHANGELOG.md
|
||||
jq -e --arg base "$prior_base" --arg merge "$prior_merge" \
|
||||
'.schema == 2 and .previous_tag == "desktop-v1.0.0" and .previous_base_sha == $base and .previous_merge_sha == $merge' \
|
||||
.release/desktop-candidate.json >/dev/null
|
||||
|
||||
cp .release/desktop-candidate.json metadata.json
|
||||
jq '.previous_merge_sha = "0000000000000000000000000000000000000000"' metadata.json > .release/desktop-candidate.json
|
||||
if PATH="$mock_bin:$PATH" scripts/desktop_release.py validate --version 1.0.1 --repo block/buzz >/dev/null 2>&1; then
|
||||
echo "validator accepted a forged previous release ledger" >&2; exit 1
|
||||
fi
|
||||
mv metadata.json .release/desktop-candidate.json
|
||||
|
||||
# Post-merge verification may be retried after this candidate's immutable tag
|
||||
# already exists. Accept only the exact candidate SHA; an equal-version tag
|
||||
# anywhere else remains a collision.
|
||||
candidate=$(git rev-parse HEAD)
|
||||
git -c tag.gpgSign=false tag desktop-v1.0.1 "$candidate"
|
||||
PATH="$mock_bin:$PATH" scripts/desktop_release.py validate --version 1.0.1 --repo block/buzz
|
||||
git -c tag.gpgSign=false tag -f desktop-v1.0.1 "$base" >/dev/null
|
||||
if PATH="$mock_bin:$PATH" scripts/desktop_release.py validate --version 1.0.1 --repo block/buzz >/dev/null 2>&1; then
|
||||
echo "validator accepted an equal-version tag at the wrong SHA" >&2; exit 1
|
||||
fi
|
||||
git tag -d desktop-v1.0.1 >/dev/null
|
||||
|
||||
# Prerelease tags are not prior-release ledgers, but the exact target tag is
|
||||
# still a collision boundary: same-SHA retry passes; wrong-SHA reuse fails.
|
||||
git -c tag.gpgSign=false tag desktop-v1.0.1-beta "$candidate"
|
||||
PATH="$mock_bin:$PATH" python3 - <<'PY'
|
||||
import importlib.util
|
||||
import pathlib
|
||||
|
||||
spec = importlib.util.spec_from_file_location("desktop_release", pathlib.Path("scripts/desktop_release.py"))
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
candidate = module.git("rev-parse", "HEAD")
|
||||
module.previous_release("1.0.1-beta", "block/buzz", allow_target_sha=candidate)
|
||||
PY
|
||||
git -c tag.gpgSign=false tag -f desktop-v1.0.1-beta "$base" >/dev/null
|
||||
if PATH="$mock_bin:$PATH" python3 - <<'PY'
|
||||
import importlib.util
|
||||
import pathlib
|
||||
|
||||
spec = importlib.util.spec_from_file_location("desktop_release", pathlib.Path("scripts/desktop_release.py"))
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
candidate = module.git("rev-parse", "HEAD")
|
||||
module.previous_release("1.0.1-beta", "block/buzz", allow_target_sha=candidate)
|
||||
PY
|
||||
then
|
||||
echo "validator accepted a prerelease target tag at the wrong SHA" >&2; exit 1
|
||||
fi
|
||||
git tag -d desktop-v1.0.1-beta >/dev/null
|
||||
|
||||
# A stable tag with the same numeric tuple is a different tag and cannot
|
||||
# authorize a prerelease retry, even when it points at the candidate.
|
||||
git -c tag.gpgSign=false tag desktop-v1.0.1 "$candidate"
|
||||
if PATH="$mock_bin:$PATH" python3 - <<'PY'
|
||||
import importlib.util
|
||||
import pathlib
|
||||
|
||||
spec = importlib.util.spec_from_file_location("desktop_release", pathlib.Path("scripts/desktop_release.py"))
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
candidate = module.git("rev-parse", "HEAD")
|
||||
module.previous_release("1.0.1-beta", "block/buzz", allow_target_sha=candidate)
|
||||
PY
|
||||
then
|
||||
echo "validator accepted a mismatched stable tag for a prerelease target" >&2; exit 1
|
||||
fi
|
||||
git tag -d desktop-v1.0.1 >/dev/null
|
||||
)
|
||||
|
||||
# Equal and decreasing versions are rejected before any GitHub lookup.
|
||||
for invalid_version in 1.0.0 0.9.9; do
|
||||
if (cd "$tmp" && PATH="/usr/bin:/bin" scripts/desktop_release.py generate "$invalid_version" --base "$base" --repo block/buzz) >/dev/null 2>&1; then
|
||||
echo "generator accepted non-increasing version $invalid_version" >&2; exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
# A production-style schema-1 tag points at its squash commit on main. It must
|
||||
# still resolve as the prior ledger during migration to head-tagged releases.
|
||||
migration=$(mktemp -d)
|
||||
git clone -q "$tmp" "$migration"
|
||||
git -C "$migration" config user.name test
|
||||
git -C "$migration" config user.email test@example.com
|
||||
git -C "$migration" checkout -q "$prior_base"
|
||||
GIT_EDITOR=true git -C "$migration" cherry-pick "$prior_candidate" >/dev/null
|
||||
production_tag=$(git -C "$migration" rev-parse HEAD)
|
||||
git -C "$migration" -c tag.gpgSign=false tag -f desktop-v1.0.0 "$production_tag" >/dev/null
|
||||
echo migration >> "$migration/desktop/feature"
|
||||
git -C "$migration" add desktop/feature
|
||||
git -C "$migration" commit -qm 'fix: migration change'
|
||||
migration_base=$(git -C "$migration" rev-parse HEAD)
|
||||
cat > "$mock_bin/gh" <<GH
|
||||
#!/usr/bin/env bash
|
||||
[[ "\$1" == api && "\$2" == "repos/block/buzz/commits/$production_tag/pulls" ]] || exit 90
|
||||
printf '%s\n' '[{"merged_at":"2026-01-01T00:00:00Z","merge_commit_sha":"$production_tag","head":{"sha":"$prior_candidate"}}]'
|
||||
GH
|
||||
chmod +x "$mock_bin/gh"
|
||||
(cd "$migration" && PATH="$mock_bin:$PATH" scripts/desktop_release.py generate 1.0.1 --base "$migration_base" --repo block/buzz)
|
||||
jq -e --arg merge "$production_tag" '.previous_tag == "desktop-v1.0.0" and .previous_merge_sha == $merge' "$migration/.release/desktop-candidate.json" >/dev/null
|
||||
rm -rf "$migration"
|
||||
|
||||
# An initial release still accounts for the root commit without calling GitHub.
|
||||
initial=$(mktemp -d)
|
||||
cp "$repo_root/scripts/desktop_release.py" "$initial/desktop_release.py"
|
||||
git -C "$initial" init -q
|
||||
git -C "$initial" config user.name test
|
||||
git -C "$initial" config user.email test@example.com
|
||||
mkdir -p "$initial/scripts" "$initial/desktop/src-tauri"
|
||||
mv "$initial/desktop_release.py" "$initial/scripts/desktop_release.py"
|
||||
printf '{"version":"0.1.0"}\n' > "$initial/desktop/package.json"
|
||||
printf '{"version":"0.1.0"}\n' > "$initial/desktop/src-tauri/tauri.conf.json"
|
||||
printf '[package]\nversion = "0.1.0"\n' > "$initial/desktop/src-tauri/Cargo.toml"
|
||||
printf '# Changelog\n' > "$initial/CHANGELOG.md"
|
||||
echo root > "$initial/ROOT.md"
|
||||
git -C "$initial" add .
|
||||
git -C "$initial" commit -qm 'feat: root release content'
|
||||
root_sha=$(git -C "$initial" rev-parse HEAD)
|
||||
(cd "$initial" && scripts/desktop_release.py generate 0.1.0 --base "$root_sha" --repo block/buzz)
|
||||
grep -Fq "$root_sha" "$initial/CHANGELOG.md"
|
||||
rm -rf "$initial"
|
||||
|
||||
echo "desktop release candidate contract passed"
|
||||
@@ -0,0 +1,78 @@
|
||||
#!/usr/bin/env bash
|
||||
# Reproduce the desktop release's multi-package build shape, then prove the
|
||||
# Kubernetes provider reports an unreachable cluster in-band rather than
|
||||
# panicking when rustls has multiple compiled crypto providers available.
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$ROOT"
|
||||
|
||||
TARGET_DIR="${CARGO_TARGET_DIR:-$ROOT/target}"
|
||||
PROVIDER_BINARY="$TARGET_DIR/release/buzz-backend-kubernetes"
|
||||
|
||||
CARGO="${CARGO:-cargo}"
|
||||
"$CARGO" build --release \
|
||||
-p buzz-acp \
|
||||
-p buzz-agent \
|
||||
-p buzz-dev-mcp \
|
||||
-p git-credential-nostr \
|
||||
-p buzz-cli \
|
||||
-p buzz-backend-kubernetes
|
||||
|
||||
TMP="$(mktemp -d)"
|
||||
trap 'rm -rf "$TMP"' EXIT
|
||||
cat >"$TMP/kubeconfig" <<'YAML'
|
||||
apiVersion: v1
|
||||
kind: Config
|
||||
clusters:
|
||||
- name: unreachable
|
||||
cluster:
|
||||
server: http://127.0.0.1:9
|
||||
contexts:
|
||||
- name: unreachable
|
||||
context:
|
||||
cluster: unreachable
|
||||
user: none
|
||||
current-context: unreachable
|
||||
users:
|
||||
- name: none
|
||||
user: {}
|
||||
YAML
|
||||
|
||||
cat >"$TMP/request.json" <<'JSON'
|
||||
{
|
||||
"op": "deploy",
|
||||
"agent": {
|
||||
"relay_url": "wss://relay.example",
|
||||
"private_key_nsec": "nsec1vl029mgpspedva04g90vltkh6fvh240zqtv9k0t9af8935ke9laqsnlfe5",
|
||||
"auth_tag": "owner",
|
||||
"launch": {"command": "goose", "args": [], "env": {}, "policy_env": {}}
|
||||
},
|
||||
"provider_config": {
|
||||
"context": "unreachable",
|
||||
"namespace": "never-created",
|
||||
"image": "example.invalid/sprig@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
"inactivity_seconds": 60
|
||||
}
|
||||
}
|
||||
JSON
|
||||
|
||||
set +e
|
||||
KUBECONFIG="$TMP/kubeconfig" \
|
||||
"$PROVIDER_BINARY" \
|
||||
<"$TMP/request.json" >"$TMP/stdout" 2>"$TMP/stderr"
|
||||
status=$?
|
||||
set -e
|
||||
|
||||
[[ "$status" == 0 ]] || {
|
||||
echo "provider exited $status instead of returning an in-band failure" >&2
|
||||
cat "$TMP/stderr" >&2
|
||||
exit 1
|
||||
}
|
||||
[[ "$(wc -l <"$TMP/stdout" | tr -d ' ')" == 1 ]]
|
||||
jq -e '.ok == false and (.error | type == "string" and length > 0)' "$TMP/stdout" >/dev/null
|
||||
if rg -i 'panic|crypto provider|no process-level' "$TMP/stdout" "$TMP/stderr"; then
|
||||
echo "provider emitted a panic/crypto-provider failure" >&2
|
||||
exit 1
|
||||
fi
|
||||
printf 'PASS: release-shaped provider returned one in-band unreachable-cluster failure\n'
|
||||
@@ -0,0 +1,97 @@
|
||||
#!/usr/bin/env bash
|
||||
# Prove that a cluster can resolve and start the exact digest-qualified Sprig
|
||||
# image that will be passed to the Kubernetes provider. This is intentionally a
|
||||
# separate preflight: a Docker image existing in the host daemon does not imply
|
||||
# that the kubelet's container runtime can resolve the same name and digest.
|
||||
set -euo pipefail
|
||||
|
||||
: "${BUZZ_K8S_TEST_CONTEXT:?set the explicit disposable/local kubectl context}"
|
||||
: "${BUZZ_SPRIG_IMAGE:?set an immutable image reference (name@sha256:<64 hex>)}"
|
||||
|
||||
if [[ ! "$BUZZ_SPRIG_IMAGE" =~ ^[^[:space:]@]+@sha256:[0-9a-fA-F]{64}$ ]]; then
|
||||
echo "error: BUZZ_SPRIG_IMAGE must be name@sha256:<64 hex>" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
CONTEXT="$BUZZ_K8S_TEST_CONTEXT"
|
||||
IMAGE="$BUZZ_SPRIG_IMAGE"
|
||||
PULL_POLICY="${BUZZ_K8S_TEST_PULL_POLICY:-IfNotPresent}"
|
||||
case "$PULL_POLICY" in
|
||||
Always|IfNotPresent|Never) ;;
|
||||
*) echo "error: invalid BUZZ_K8S_TEST_PULL_POLICY: $PULL_POLICY" >&2; exit 2 ;;
|
||||
esac
|
||||
|
||||
MANAGED_BY="buzz-backend-kubernetes"
|
||||
BINDING_VERSION="v1"
|
||||
NAMESPACE="buzz-k8s-sprig-$(date +%s)-$RANDOM"
|
||||
CREATED=0
|
||||
|
||||
cleanup() {
|
||||
(( CREATED == 1 )) || return 0
|
||||
local managed binding foreign
|
||||
managed="$(kubectl --context "$CONTEXT" get namespace "$NAMESPACE" \
|
||||
-o jsonpath='{.metadata.labels.app\.kubernetes\.io/managed-by}' 2>/dev/null || true)"
|
||||
binding="$(kubectl --context "$CONTEXT" get namespace "$NAMESPACE" \
|
||||
-o jsonpath='{.metadata.labels.buzz\.block\.xyz/binding-version}' 2>/dev/null || true)"
|
||||
if [[ "$managed" != "$MANAGED_BY" || "$binding" != "$BINDING_VERSION" ]]; then
|
||||
echo "REFUSING cleanup: namespace ownership markers changed: $NAMESPACE" >&2
|
||||
return 1
|
||||
fi
|
||||
foreign="$(kubectl --context "$CONTEXT" --namespace "$NAMESPACE" get pods -o json \
|
||||
| jq '[.items[] | select(.metadata.labels["app.kubernetes.io/managed-by"] != "buzz-backend-kubernetes" or .metadata.labels["buzz.block.xyz/binding-version"] != "v1")] | length')"
|
||||
if [[ "$foreign" != 0 ]]; then
|
||||
echo "REFUSING cleanup: namespace contains an unowned pod: $NAMESPACE" >&2
|
||||
return 1
|
||||
fi
|
||||
kubectl --context "$CONTEXT" delete namespace "$NAMESPACE" --wait=true
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
# Fail before mutation if the named context is absent or inaccessible. Print the
|
||||
# cluster identity so evidence cannot be mistaken for a different kubeconfig.
|
||||
kubectl config get-contexts "$CONTEXT" >/dev/null
|
||||
SERVER="$(kubectl config view --minify --context "$CONTEXT" -o jsonpath='{.clusters[0].cluster.server}')"
|
||||
kubectl --context "$CONTEXT" get nodes -o name >/dev/null
|
||||
printf 'context=%s\nserver=%s\nimage=%s\npull_policy=%s\nnamespace=%s\n' \
|
||||
"$CONTEXT" "$SERVER" "$IMAGE" "$PULL_POLICY" "$NAMESPACE"
|
||||
|
||||
kubectl --context "$CONTEXT" create namespace "$NAMESPACE"
|
||||
CREATED=1
|
||||
kubectl --context "$CONTEXT" label namespace "$NAMESPACE" \
|
||||
"app.kubernetes.io/managed-by=$MANAGED_BY" \
|
||||
"buzz.block.xyz/binding-version=$BINDING_VERSION"
|
||||
|
||||
cat <<YAML | kubectl --context "$CONTEXT" --namespace "$NAMESPACE" apply -f -
|
||||
apiVersion: v1
|
||||
kind: Pod
|
||||
metadata:
|
||||
name: digest-resolution-probe
|
||||
labels:
|
||||
app.kubernetes.io/managed-by: $MANAGED_BY
|
||||
buzz.block.xyz/binding-version: $BINDING_VERSION
|
||||
spec:
|
||||
restartPolicy: Never
|
||||
containers:
|
||||
- name: probe
|
||||
image: $IMAGE
|
||||
imagePullPolicy: $PULL_POLICY
|
||||
command: [/bin/bash, -ceu]
|
||||
args:
|
||||
- 'test "\$(readlink /usr/local/bin/buzz-acp)" = sprig; echo DIGEST_ABI_OK'
|
||||
YAML
|
||||
|
||||
if ! kubectl --context "$CONTEXT" --namespace "$NAMESPACE" wait \
|
||||
--for=jsonpath='{.status.phase}'=Succeeded pod/digest-resolution-probe --timeout=120s; then
|
||||
kubectl --context "$CONTEXT" --namespace "$NAMESPACE" describe pod digest-resolution-probe >&2 || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
kubectl --context "$CONTEXT" --namespace "$NAMESPACE" logs digest-resolution-probe
|
||||
IMAGE_ID="$(kubectl --context "$CONTEXT" --namespace "$NAMESPACE" get pod digest-resolution-probe \
|
||||
-o jsonpath='{.status.containerStatuses[0].imageID}')"
|
||||
RESOLVED_SPEC="$(kubectl --context "$CONTEXT" --namespace "$NAMESPACE" get pod digest-resolution-probe \
|
||||
-o jsonpath='{.spec.containers[0].image}')"
|
||||
[[ "$RESOLVED_SPEC" == "$IMAGE" ]]
|
||||
[[ -n "$IMAGE_ID" ]]
|
||||
printf 'resolved_spec=%s\nimage_id=%s\nPASS: exact digest-qualified Sprig reference started\n' \
|
||||
"$RESOLVED_SPEC" "$IMAGE_ID"
|
||||
@@ -0,0 +1,157 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
publisher="$repo_root/scripts/publish-mobile-release-candidate.sh"
|
||||
tmp="$(mktemp -d)"
|
||||
trap 'rm -rf "$tmp"' EXIT
|
||||
|
||||
bin="$tmp/bin"
|
||||
mkdir -p "$bin"
|
||||
cat > "$bin/gh" <<'GH'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
record() {
|
||||
printf '%s\n' "$*" >> "$GH_CALLS"
|
||||
}
|
||||
|
||||
case "${1:-}:${2:-}" in
|
||||
api:repos/block/buzz/rulesets/14378754)
|
||||
case "$*" in
|
||||
*'.enforcement'*) printf '%s\n' "${GH_TAG_RULESET_STATE:-active}" ;;
|
||||
*'.current_user_can_bypass'*) printf '%s\n' "${GH_CURRENT_USER_CAN_BYPASS-always}" ;;
|
||||
*'[.rules[].type]'*) printf '%s\n' "${GH_TAG_RULE_TYPES:-creation,deletion,non_fast_forward,update}" ;;
|
||||
*'.conditions.ref_name.include[]'*) printf '%s\n' "${GH_TAG_INCLUDES:-refs/tags/mobile-v*}" ;;
|
||||
*'.conditions.ref_name.exclude[]'*) printf '%s\n' "${GH_TAG_EXCLUDES:-}" ;;
|
||||
*) exit 2 ;;
|
||||
esac
|
||||
;;
|
||||
api:repos/block/buzz/git/ref/heads/main) printf '%s\n' "$GH_TARGET_SHA" ;;
|
||||
api:repos/block/buzz/commits/*) printf '%s\n' "$GH_TARGET_SHA" ;;
|
||||
api:--paginate)
|
||||
[[ "$3" == "repos/block/buzz/git/matching-refs/tags/mobile-v1.2.3-rc." ]]
|
||||
printf '%s' "${GH_EXISTING_REFS:-}"
|
||||
;;
|
||||
api:--method)
|
||||
endpoint="$4"
|
||||
case "$endpoint" in
|
||||
repos/block/buzz/git/tags)
|
||||
record "$*"
|
||||
printf '%s\n' "$GH_TAG_OBJECT_SHA"
|
||||
;;
|
||||
repos/block/buzz/git/refs)
|
||||
record "$*"
|
||||
;;
|
||||
*) exit 2 ;;
|
||||
esac
|
||||
;;
|
||||
api:repos/block/buzz/git/ref/tags/mobile-v1.2.3-rc.*)
|
||||
if [[ "$*" == *'.object.type'* ]]; then
|
||||
printf '%s\n' "${GH_PUBLISHED_REF_TYPE:-tag}"
|
||||
else
|
||||
printf '%s\n' "${GH_PUBLISHED_REF_SHA:-$GH_TAG_OBJECT_SHA}"
|
||||
fi
|
||||
;;
|
||||
api:repos/block/buzz/git/tags/*)
|
||||
if [[ "$*" == *'.object.type'* ]]; then
|
||||
printf '%s\n' "${GH_ANNOTATED_TARGET_TYPE:-commit}"
|
||||
else
|
||||
printf '%s\n' "${GH_ANNOTATED_TARGET_SHA:-$GH_TARGET_SHA}"
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
echo "unexpected gh call: $*" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
GH
|
||||
chmod +x "$bin/gh"
|
||||
|
||||
export PATH="$bin:$PATH"
|
||||
export GH_CALLS="$tmp/calls"
|
||||
export GITHUB_REPOSITORY=block/buzz
|
||||
export GH_TARGET_SHA=1111111111111111111111111111111111111111
|
||||
export GH_TAG_OBJECT_SHA=2222222222222222222222222222222222222222
|
||||
|
||||
"$publisher" 1.2.3 1 "$GH_TARGET_SHA"
|
||||
grep -Fq -- '-f tag=mobile-v1.2.3-rc.1' "$GH_CALLS"
|
||||
grep -Fq -- '-f message=Buzz Mobile 1.2.3 release candidate 1' "$GH_CALLS"
|
||||
grep -Fq -- "-f object=$GH_TARGET_SHA" "$GH_CALLS"
|
||||
grep -Fq -- '-f type=commit' "$GH_CALLS"
|
||||
grep -Fq -- '-f ref=refs/tags/mobile-v1.2.3-rc.1' "$GH_CALLS"
|
||||
grep -Fq -- "-f sha=$GH_TAG_OBJECT_SHA" "$GH_CALLS"
|
||||
|
||||
if GH_CURRENT_USER_CAN_BYPASS=never "$publisher" 1.2.3 1 "$GH_TARGET_SHA" >/dev/null 2>&1; then
|
||||
echo "publisher accepted an App token without an always bypass" >&2
|
||||
exit 1
|
||||
fi
|
||||
if GH_CURRENT_USER_CAN_BYPASS='' "$publisher" 1.2.3 1 "$GH_TARGET_SHA" >/dev/null 2>&1; then
|
||||
echo "publisher accepted a ruleset response without an effective bypass" >&2
|
||||
exit 1
|
||||
fi
|
||||
if GH_TAG_RULESET_STATE=disabled "$publisher" 1.2.3 1 "$GH_TARGET_SHA" >/dev/null 2>&1; then
|
||||
echo "publisher accepted disabled tag protection" >&2
|
||||
exit 1
|
||||
fi
|
||||
if GH_TAG_RULE_TYPES=creation "$publisher" 1.2.3 1 "$GH_TARGET_SHA" >/dev/null 2>&1; then
|
||||
echo "publisher accepted incomplete tag protection" >&2
|
||||
exit 1
|
||||
fi
|
||||
if GH_TAG_INCLUDES=refs/tags/v\* "$publisher" 1.2.3 1 "$GH_TARGET_SHA" >/dev/null 2>&1; then
|
||||
echo "publisher accepted a tag ruleset that excludes mobile candidates" >&2
|
||||
exit 1
|
||||
fi
|
||||
if GH_TAG_EXCLUDES=refs/tags/mobile-v0.0.0 "$publisher" 1.2.3 1 "$GH_TARGET_SHA" >/dev/null 2>&1; then
|
||||
echo "publisher accepted tag ruleset exclusions" >&2
|
||||
exit 1
|
||||
fi
|
||||
if GH_TARGET_SHA=3333333333333333333333333333333333333333 \
|
||||
"$publisher" 1.2.3 1 1111111111111111111111111111111111111111 >/dev/null 2>&1; then
|
||||
echo "publisher accepted a moved main branch" >&2
|
||||
exit 1
|
||||
fi
|
||||
if GH_EXISTING_REFS=$'refs/tags/mobile-v1.2.3-rc.1\n' \
|
||||
"$publisher" 1.2.3 1 "$GH_TARGET_SHA" >/dev/null 2>&1; then
|
||||
echo "publisher accepted a stale candidate number" >&2
|
||||
exit 1
|
||||
fi
|
||||
if GH_PUBLISHED_REF_TYPE=commit "$publisher" 1.2.3 1 "$GH_TARGET_SHA" >/dev/null 2>&1; then
|
||||
echo "publisher accepted a lightweight published tag" >&2
|
||||
exit 1
|
||||
fi
|
||||
if GH_PUBLISHED_REF_SHA=3333333333333333333333333333333333333333 \
|
||||
"$publisher" 1.2.3 1 "$GH_TARGET_SHA" >/dev/null 2>&1; then
|
||||
echo "publisher accepted the wrong annotated tag object" >&2
|
||||
exit 1
|
||||
fi
|
||||
if GH_ANNOTATED_TARGET_TYPE=tag "$publisher" 1.2.3 1 "$GH_TARGET_SHA" >/dev/null 2>&1; then
|
||||
echo "publisher accepted a nested annotated tag" >&2
|
||||
exit 1
|
||||
fi
|
||||
if GH_ANNOTATED_TARGET_SHA=3333333333333333333333333333333333333333 \
|
||||
"$publisher" 1.2.3 1 "$GH_TARGET_SHA" >/dev/null 2>&1; then
|
||||
echo "publisher accepted an annotated tag on the wrong commit" >&2
|
||||
exit 1
|
||||
fi
|
||||
if "$publisher" 01.2.3 1 "$GH_TARGET_SHA" >/dev/null 2>&1; then
|
||||
echo "publisher accepted a marketing version with a leading zero" >&2
|
||||
exit 1
|
||||
fi
|
||||
if "$publisher" 1.2.3 01 "$GH_TARGET_SHA" >/dev/null 2>&1; then
|
||||
echo "publisher accepted a candidate number with a leading zero" >&2
|
||||
exit 1
|
||||
fi
|
||||
if GH_EXISTING_REFS=$'refs/tags/mobile-v1.2.3-rc.1\nrefs/tags/mobile-v1.2.3-rc.7\nrefs/tags/mobile-v1.2.3-rc.08\nrefs/tags/mobile-v1.2.4-rc.99\n' \
|
||||
"$publisher" 1.2.3 8 "$GH_TARGET_SHA" >/dev/null; then
|
||||
:
|
||||
else
|
||||
echo "publisher did not sequence from the highest exact candidate" >&2
|
||||
exit 1
|
||||
fi
|
||||
if GITHUB_REPOSITORY=attacker/buzz "$publisher" 1.2.3 1 "$GH_TARGET_SHA" >/dev/null 2>&1; then
|
||||
echo "publisher accepted the wrong repository" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "mobile release candidate publisher contract passed"
|
||||
@@ -0,0 +1,301 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
script="$repo_root/scripts/mobile-release.sh"
|
||||
tmp="$(mktemp -d)"
|
||||
trap 'rm -rf "$tmp"' EXIT
|
||||
remote="$tmp/remote.git"
|
||||
work="$tmp/work"
|
||||
operator="$tmp/operator"
|
||||
bin="$tmp/bin"
|
||||
canonical_origin="git@github.com:block/buzz.git"
|
||||
mkdir -p "$bin"
|
||||
|
||||
cat > "$bin/gh" <<'GH'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
case "${1:-}:${2:-}" in
|
||||
--version:*) printf 'gh version %s (test)\n' "${GH_VERSION:-2.94.0}" ;;
|
||||
api:repos/block/buzz/rulesets/14378754)
|
||||
case "$*" in
|
||||
*'.enforcement'*) printf '%s\n' "${GH_TAG_RULESET_STATE:-active}" ;;
|
||||
*'.current_user_can_bypass'*) printf '%s\n' "${GH_CURRENT_USER_CAN_BYPASS-always}" ;;
|
||||
*'[.rules[].type]'*) printf '%s\n' "${GH_TAG_RULE_TYPES:-creation,deletion,non_fast_forward,update}" ;;
|
||||
*'.conditions.ref_name.include[]'*) printf '%s\n' "${GH_TAG_INCLUDES:-refs/tags/mobile-v*}" ;;
|
||||
*'.conditions.ref_name.exclude[]'*) printf '%s\n' "${GH_TAG_EXCLUDES:-}" ;;
|
||||
*) exit 2 ;;
|
||||
esac
|
||||
;;
|
||||
workflow:run)
|
||||
if [[ "${GH_WORKFLOW_DISPATCH_FAIL:-}" == "1" ]]; then
|
||||
printf '%s\n' "${GH_WORKFLOW_DISPATCH_ERROR:-dispatch failed}" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ "${GH_WORKFLOW_WRONG_URL:-}" == "1" ]]; then
|
||||
printf '%s\n' 'https://github.com/attacker/buzz/actions/runs/999'
|
||||
exit 0
|
||||
fi
|
||||
if [[ "${GH_WORKFLOW_EXTRA_URL:-}" == "1" ]]; then
|
||||
printf '%s\n' 'https://github.com/block/buzz/actions/runs/998'
|
||||
fi
|
||||
version=""
|
||||
number=""
|
||||
sha=""
|
||||
while [[ "$#" -gt 0 ]]; do
|
||||
case "$1" in
|
||||
version=*) version="${1#version=}" ;;
|
||||
candidate_number=*) number="${1#candidate_number=}" ;;
|
||||
target_sha=*) sha="${1#target_sha=}" ;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
[[ -n "$version" && -n "$number" && -n "$sha" ]]
|
||||
printf '%s\t%s\t%s\n' "$version" "$number" "$sha" >> "$GH_WORKFLOW_CAPTURE"
|
||||
if [[ "${GH_WORKFLOW_NO_URL:-}" == "1" ]]; then
|
||||
exit 0
|
||||
fi
|
||||
printf 'https://github.com/block/buzz/actions/runs/%s\n' "$number"
|
||||
;;
|
||||
run:watch)
|
||||
[[ "${GH_WORKFLOW_FAIL:-}" != "1" ]] || exit 1
|
||||
number="$3"
|
||||
IFS=$'\t' read -r version expected sha < <(tail -n 1 "$GH_WORKFLOW_CAPTURE")
|
||||
[[ "$number" == "$expected" ]]
|
||||
if [[ "${GH_MAIN_MOVE_DURING_WORKFLOW:-}" == "1" ]]; then
|
||||
printf '%s\n' moved >> "$GH_WORKTREE/file"
|
||||
git -C "$GH_WORKTREE" commit -qam moved-during-publication
|
||||
git -C "$GH_WORKTREE" push -q origin main
|
||||
fi
|
||||
if [[ "${GH_TAG_VERIFY_LIGHTWEIGHT:-}" == "1" ]]; then
|
||||
git -C "$GH_WORKTREE" -c tag.gpgSign=false tag \
|
||||
"mobile-v${version}-rc.${expected}" "$sha"
|
||||
else
|
||||
git -C "$GH_WORKTREE" -c tag.gpgSign=false tag -a \
|
||||
-m "Buzz Mobile $version release candidate $expected" \
|
||||
"mobile-v${version}-rc.${expected}" "$sha"
|
||||
fi
|
||||
git -C "$GH_WORKTREE" -c core.hooksPath=/dev/null push -q \
|
||||
origin "refs/tags/mobile-v${version}-rc.${expected}"
|
||||
;;
|
||||
*) exit 2 ;;
|
||||
esac
|
||||
GH
|
||||
chmod +x "$bin/gh"
|
||||
|
||||
export PATH="$bin:$PATH"
|
||||
export GH_WORKFLOW_CAPTURE="$tmp/workflow-dispatches"
|
||||
export GH_WORKTREE="$work"
|
||||
|
||||
run_release() {
|
||||
local repo="$1"
|
||||
shift
|
||||
(
|
||||
cd "$repo"
|
||||
git config "url.file://$remote.insteadOf" "$canonical_origin"
|
||||
git config protocol.file.allow always
|
||||
"$script" "$@"
|
||||
)
|
||||
}
|
||||
|
||||
fail() {
|
||||
echo "$*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
assert_no_removed_mobile_release_behavior() {
|
||||
local status
|
||||
|
||||
grep -Eq 'gh[[:space:]]+release|mobile-release/|finalize' "$@" && \
|
||||
fail "removed branch/finalization/GitHub Release behavior remains"
|
||||
status="$?"
|
||||
[[ "$status" -eq 1 ]] || \
|
||||
fail "could not scan for removed branch/finalization/GitHub Release behavior"
|
||||
}
|
||||
|
||||
git init -q --bare "$remote"
|
||||
git init -q "$work"
|
||||
git -C "$work" config user.name test
|
||||
git -C "$work" config user.email test@example.com
|
||||
git -C "$work" remote add origin "$canonical_origin"
|
||||
git -C "$work" config "url.file://$remote.insteadOf" "$canonical_origin"
|
||||
git -C "$work" config protocol.file.allow always
|
||||
echo first > "$work/file"
|
||||
git -C "$work" add file
|
||||
git -C "$work" commit -qm first
|
||||
git -C "$work" branch -M main
|
||||
git --git-dir="$remote" symbolic-ref HEAD refs/heads/main
|
||||
git -C "$work" push -q -u origin main
|
||||
|
||||
# Candidate publication must work from a stale operator clone, warn about the
|
||||
# stale checkout, and target the exact current remote main commit.
|
||||
git -c "url.file://$remote.insteadOf=$canonical_origin" \
|
||||
-c protocol.file.allow=always clone -q "$canonical_origin" "$operator"
|
||||
git -C "$operator" config user.name test
|
||||
git -C "$operator" config user.email test@example.com
|
||||
echo remote-only >> "$work/file"
|
||||
git -C "$work" commit -qam remote-only
|
||||
git -C "$work" push -q origin main
|
||||
remote_main_sha="$(git --git-dir="$remote" rev-parse refs/heads/main)"
|
||||
if git -C "$operator" cat-file -e "$remote_main_sha^{commit}" 2>/dev/null; then
|
||||
fail "stale-clone fixture already contains the remote-only commit"
|
||||
fi
|
||||
run_release "$operator" candidate 1.2.3 > "$tmp/stale-output" 2> "$tmp/stale-error"
|
||||
grep -Fq "Note: local HEAD is $(git -C "$operator" rev-parse HEAD); candidate source is current origin/main $remote_main_sha." \
|
||||
"$tmp/stale-error"
|
||||
cat "$tmp/stale-output"
|
||||
[[ "$(git --git-dir="$remote" rev-parse 'refs/tags/mobile-v1.2.3-rc.1^{commit}')" == \
|
||||
"$remote_main_sha" ]]
|
||||
[[ "$(git --git-dir="$remote" cat-file -t refs/tags/mobile-v1.2.3-rc.1)" == tag ]]
|
||||
grep -Fq $'1.2.3\t1\t' "$GH_WORKFLOW_CAPTURE"
|
||||
|
||||
# Existing remote identities remain unchanged. Later candidates sequence
|
||||
# monotonically and target the then-current remote main commit.
|
||||
rc1_tag_oid="$(git --git-dir="$remote" rev-parse refs/tags/mobile-v1.2.3-rc.1)"
|
||||
echo newer >> "$work/file"
|
||||
git -C "$work" commit -qam newer
|
||||
git -C "$work" push -q origin main
|
||||
new_main_sha="$(git --git-dir="$remote" rev-parse refs/heads/main)"
|
||||
run_release "$operator" candidate 1.2.3
|
||||
[[ "$(git --git-dir="$remote" rev-parse refs/tags/mobile-v1.2.3-rc.1)" == "$rc1_tag_oid" ]]
|
||||
[[ "$(git --git-dir="$remote" rev-parse 'refs/tags/mobile-v1.2.3-rc.2^{commit}')" == \
|
||||
"$new_main_sha" ]]
|
||||
[[ "$(git --git-dir="$remote" cat-file -t refs/tags/mobile-v1.2.3-rc.2)" == tag ]]
|
||||
grep -Fq $'1.2.3\t2\t' "$GH_WORKFLOW_CAPTURE"
|
||||
|
||||
# Sequence from the highest exact remote RC even if there are gaps, and ignore
|
||||
# malformed or other-version tags.
|
||||
git -C "$work" -c tag.gpgSign=false tag -a -m gap mobile-v1.2.3-rc.7 "$new_main_sha"
|
||||
git -C "$work" -c tag.gpgSign=false tag -a -m malformed mobile-v1.2.3-rc.08 "$new_main_sha"
|
||||
git -C "$work" -c tag.gpgSign=false tag -a -m other mobile-v1.2.4-rc.99 "$new_main_sha"
|
||||
git -C "$work" push -q origin \
|
||||
refs/tags/mobile-v1.2.3-rc.7 refs/tags/mobile-v1.2.3-rc.08 \
|
||||
refs/tags/mobile-v1.2.4-rc.99
|
||||
run_release "$operator" candidate 1.2.3
|
||||
[[ "$(git --git-dir="$remote" rev-parse 'refs/tags/mobile-v1.2.3-rc.8^{commit}')" == \
|
||||
"$new_main_sha" ]]
|
||||
|
||||
# Failed or unattributable App-backed publication fails closed without creating
|
||||
# the expected candidate tag.
|
||||
if GH_WORKFLOW_DISPATCH_FAIL=1 run_release "$operator" candidate 9.9.7 >/dev/null 2>&1; then
|
||||
fail "candidate succeeded despite a rejected workflow dispatch"
|
||||
fi
|
||||
if git --git-dir="$remote" show-ref --verify --quiet refs/tags/mobile-v9.9.7-rc.1; then
|
||||
fail "rejected workflow dispatch created a candidate tag"
|
||||
fi
|
||||
if GH_WORKFLOW_DISPATCH_FAIL=1 \
|
||||
GH_WORKFLOW_DISPATCH_ERROR="does not have 'workflow_dispatch' trigger" \
|
||||
run_release "$operator" candidate 9.9.6 > "$tmp/missing-workflow-output" 2>&1; then
|
||||
fail "candidate succeeded without the publication workflow on main"
|
||||
fi
|
||||
grep -Fq 'merge the release-process change before publishing a candidate' \
|
||||
"$tmp/missing-workflow-output"
|
||||
if git --git-dir="$remote" show-ref --verify --quiet refs/tags/mobile-v9.9.6-rc.1; then
|
||||
fail "missing publication workflow created a candidate tag"
|
||||
fi
|
||||
if GH_WORKFLOW_FAIL=1 run_release "$operator" candidate 9.9.9 >/dev/null 2>&1; then
|
||||
fail "candidate succeeded despite a failed App-backed workflow"
|
||||
fi
|
||||
if git --git-dir="$remote" show-ref --verify --quiet refs/tags/mobile-v9.9.9-rc.1; then
|
||||
fail "failed App-backed workflow created a candidate tag"
|
||||
fi
|
||||
if GH_WORKFLOW_NO_URL=1 run_release "$operator" candidate 9.9.8 >/dev/null 2>&1; then
|
||||
fail "candidate succeeded without a workflow run URL"
|
||||
fi
|
||||
if git --git-dir="$remote" show-ref --verify --quiet refs/tags/mobile-v9.9.8-rc.1; then
|
||||
fail "URL-less dispatch created a candidate tag"
|
||||
fi
|
||||
if GH_WORKFLOW_WRONG_URL=1 run_release "$operator" candidate 9.9.5 >/dev/null 2>&1; then
|
||||
fail "candidate accepted a workflow run URL from another repository"
|
||||
fi
|
||||
if git --git-dir="$remote" show-ref --verify --quiet refs/tags/mobile-v9.9.5-rc.1; then
|
||||
fail "wrong-repository workflow URL created a candidate tag"
|
||||
fi
|
||||
if GH_WORKFLOW_EXTRA_URL=1 run_release "$operator" candidate 9.9.4 >/dev/null 2>&1; then
|
||||
fail "candidate accepted multiple workflow run URLs"
|
||||
fi
|
||||
if git --git-dir="$remote" show-ref --verify --quiet refs/tags/mobile-v9.9.4-rc.1; then
|
||||
fail "ambiguous workflow URLs created a candidate tag"
|
||||
fi
|
||||
if GH_TAG_VERIFY_LIGHTWEIGHT=1 run_release "$operator" candidate 9.9.2 >/dev/null 2>&1; then
|
||||
fail "candidate accepted a lightweight published tag"
|
||||
fi
|
||||
[[ "$(git --git-dir="$remote" cat-file -t refs/tags/mobile-v9.9.2-rc.1)" == commit ]]
|
||||
|
||||
# The publisher and operator both reject a main-tip race. The immutable tag may
|
||||
# already exist at the prior tip, but the operator must not report it as a
|
||||
# current-main candidate.
|
||||
pre_race_main_sha="$(git --git-dir="$remote" rev-parse refs/heads/main)"
|
||||
if GH_MAIN_MOVE_DURING_WORKFLOW=1 run_release "$operator" candidate 9.9.3 > "$tmp/main-race-output" 2>&1; then
|
||||
fail "candidate succeeded after main moved during publication"
|
||||
fi
|
||||
post_race_main_sha="$(git --git-dir="$remote" rev-parse refs/heads/main)"
|
||||
[[ "$pre_race_main_sha" != "$post_race_main_sha" ]]
|
||||
[[ "$(git --git-dir="$remote" rev-parse 'refs/tags/mobile-v9.9.3-rc.1^{commit}')" == \
|
||||
"$pre_race_main_sha" ]]
|
||||
grep -Fq "origin/main moved from requested commit $pre_race_main_sha to $post_race_main_sha during publication" \
|
||||
"$tmp/main-race-output"
|
||||
|
||||
# Publishing through a fork is rejected and unsupported gh versions fail before
|
||||
# dispatching any candidate publication.
|
||||
fork_operator="$tmp/fork-operator"
|
||||
git clone -q "$remote" "$fork_operator"
|
||||
git -C "$fork_operator" config user.name test
|
||||
git -C "$fork_operator" config user.email test@example.com
|
||||
if (cd "$fork_operator" && "$script" candidate 2.0.0 >/dev/null 2>&1); then
|
||||
fail "noncanonical origin was accepted"
|
||||
fi
|
||||
before_dispatches="$(wc -l < "$GH_WORKFLOW_CAPTURE")"
|
||||
if GH_VERSION=2.86.0 run_release "$operator" candidate 2.0.0 >/dev/null 2>&1; then
|
||||
fail "gh older than 2.87.0 was accepted"
|
||||
fi
|
||||
if GH_VERSION=2.9.0 run_release "$operator" candidate 2.0.0 >/dev/null 2>&1; then
|
||||
fail "numeric gh version comparison accepted 2.9.0 as at least 2.87.0"
|
||||
fi
|
||||
[[ "$(wc -l < "$GH_WORKFLOW_CAPTURE")" == "$before_dispatches" ]]
|
||||
|
||||
# Dirty trees and invalid marketing versions fail before publication.
|
||||
echo dirty > "$operator/untracked"
|
||||
if run_release "$operator" candidate 2.0.0 >/dev/null 2>&1; then
|
||||
fail "dirty operator tree was accepted"
|
||||
fi
|
||||
rm "$operator/untracked"
|
||||
if run_release "$operator" candidate 1.2 >/dev/null 2>&1; then
|
||||
fail "invalid marketing version was accepted"
|
||||
fi
|
||||
if run_release "$operator" candidate 01.2.3 >/dev/null 2>&1; then
|
||||
fail "marketing version with a leading zero was accepted"
|
||||
fi
|
||||
|
||||
# Mobile no longer has release branches, finalization, a stable alias, or a
|
||||
# GitHub Release call. Publication remains App-backed because the strict tag
|
||||
# ruleset denies direct human creation.
|
||||
if run_release "$operator" start 2.0.0 >/dev/null 2>&1; then
|
||||
fail "removed start command was accepted"
|
||||
fi
|
||||
if run_release "$operator" finalize 1.2.3-rc.2 >/dev/null 2>&1; then
|
||||
fail "removed finalize command was accepted"
|
||||
fi
|
||||
if git --git-dir="$remote" for-each-ref --format='%(refname)' refs/heads/mobile-release/ | grep -q .; then
|
||||
fail "mobile release branch was created"
|
||||
fi
|
||||
if git --git-dir="$remote" show-ref --verify --quiet refs/tags/mobile-v1.2.3; then
|
||||
fail "stable mobile tag alias was created"
|
||||
fi
|
||||
assert_no_removed_mobile_release_behavior \
|
||||
"$repo_root/scripts/mobile-release.sh" \
|
||||
"$repo_root/scripts/publish-mobile-release-candidate.sh" \
|
||||
"$repo_root/.github/workflows/mobile-release-candidate.yml"
|
||||
# Prove the negative contract itself is discriminating rather than merely
|
||||
# accepting a missing or broken search tool as "not found."
|
||||
printf '%s\n' 'gh release create forbidden' > "$tmp/forbidden-mobile-release-behavior"
|
||||
if (assert_no_removed_mobile_release_behavior "$tmp/forbidden-mobile-release-behavior") \
|
||||
>/dev/null 2>&1; then
|
||||
fail "removed-behavior assertion did not reject a forbidden GitHub Release call"
|
||||
fi
|
||||
grep -Fq 'version: 0.0.0+1' "$repo_root/mobile/pubspec.yaml"
|
||||
if grep -qE 'release-mobile|bump-mobile-version|get-current-mobile-version' "$repo_root/Justfile"; then
|
||||
fail "metadata-only mobile release recipe remains in Justfile"
|
||||
fi
|
||||
|
||||
echo "mobile release contract passed"
|
||||
@@ -0,0 +1,249 @@
|
||||
#!/usr/bin/env bash
|
||||
# Regression tests for the mobile worktree identity contract:
|
||||
# - scripts/mobile-worktree-overrides.sh writes debug-only override files in
|
||||
# a worktree and removes them in the main checkout.
|
||||
# - install identity is keyed to the worktree DIRECTORY (stable across
|
||||
# branch switches); the branch (or short SHA when detached) is a
|
||||
# display-only label sanitized to [A-Za-z0-9._-].
|
||||
# - the tracked iOS/Android build files keep production identity, only
|
||||
# consume the overrides in debug configurations, and let a developer's
|
||||
# AppOverrides.xcconfig take precedence over the worktree defaults.
|
||||
# - scripts/mobile-worktree-clean.sh only ever targets suffixed installs,
|
||||
# never the production app ids.
|
||||
set -euo pipefail
|
||||
|
||||
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
script="$repo_root/scripts/mobile-worktree-overrides.sh"
|
||||
clean_script="$repo_root/scripts/mobile-worktree-clean.sh"
|
||||
tmp="$(mktemp -d)"
|
||||
trap 'rm -rf "$tmp"' EXIT
|
||||
|
||||
failures=0
|
||||
fail() {
|
||||
printf 'FAIL: %s\n' "$1" >&2
|
||||
failures=$((failures + 1))
|
||||
}
|
||||
pass() {
|
||||
printf 'ok: %s\n' "$1"
|
||||
}
|
||||
|
||||
make_repo() {
|
||||
# $1: repo dir, $2: initial branch name
|
||||
local repo="$1" branch="$2"
|
||||
mkdir -p "$repo/scripts" "$repo/mobile/ios/Flutter" "$repo/mobile/android"
|
||||
cp "$script" "$repo/scripts/mobile-worktree-overrides.sh"
|
||||
git -C "$repo" init -q -b "$branch"
|
||||
git -C "$repo" -c user.name=t -c user.email=t@t commit -q --allow-empty -m init
|
||||
}
|
||||
|
||||
make_worktree() {
|
||||
# $1: source repo, $2: worktree dir, $3: branch to create
|
||||
local repo="$1" wt="$2" branch="$3"
|
||||
git -C "$repo" worktree add -q -b "$branch" "$wt"
|
||||
mkdir -p "$wt/scripts" "$wt/mobile/ios/Flutter" "$wt/mobile/android"
|
||||
cp "$script" "$wt/scripts/mobile-worktree-overrides.sh"
|
||||
}
|
||||
|
||||
# ── Main checkout: no overrides, stale files removed ─────────────────────────
|
||||
repo="$tmp/main-checkout"
|
||||
make_repo "$repo" main
|
||||
echo stale > "$repo/mobile/ios/Flutter/WorktreeOverrides.xcconfig"
|
||||
echo stale > "$repo/mobile/android/worktree.properties"
|
||||
"$repo/scripts/mobile-worktree-overrides.sh" > /dev/null
|
||||
if [[ -e "$repo/mobile/ios/Flutter/WorktreeOverrides.xcconfig" || -e "$repo/mobile/android/worktree.properties" ]]; then
|
||||
fail "main checkout must remove stale worktree override files"
|
||||
else
|
||||
pass "main checkout removes stale worktree override files"
|
||||
fi
|
||||
|
||||
# ── Worktree: identity from DIRECTORY name, label from branch ────────────────
|
||||
wt="$tmp/Feature_Work-1"
|
||||
make_worktree "$repo" "$wt" "tho/Fix_Thing-2"
|
||||
out="$("$wt/scripts/mobile-worktree-overrides.sh")"
|
||||
ios="$wt/mobile/ios/Flutter/WorktreeOverrides.xcconfig"
|
||||
android="$wt/mobile/android/worktree.properties"
|
||||
[[ -f "$ios" && -f "$android" ]] || fail "worktree must write both override files"
|
||||
grep -q '^BUNDLE_IDENTIFIER = com\.buzz\.buzzMobile\.feature-work-1$' "$ios" \
|
||||
&& pass "iOS bundle identifier keys to the sanitized worktree directory name" \
|
||||
|| fail "iOS bundle identifier must key to the worktree dir, got: $(cat "$ios")"
|
||||
grep -q '^APP_DISPLAY_NAME = Buzz (Fix_Thing-2)$' "$ios" \
|
||||
&& pass "iOS display name carries the branch label" \
|
||||
|| fail "iOS display name wrong: $(cat "$ios")"
|
||||
grep -q '^label=Fix_Thing-2$' "$android" \
|
||||
&& pass "Android label carries the branch label" \
|
||||
|| fail "Android label wrong: $(cat "$android")"
|
||||
grep -q '^applicationIdSuffix=\.feature_work_1$' "$android" \
|
||||
&& pass "Android applicationIdSuffix keys to the worktree directory name" \
|
||||
|| fail "Android applicationIdSuffix wrong: $(cat "$android")"
|
||||
printf '%s' "$out" | grep -q 'Worktree Feature_Work-1' \
|
||||
&& pass "worktree run reports the worktree name" \
|
||||
|| fail "worktree run must report the worktree name, got: $out"
|
||||
|
||||
# ── Branch switch in the same worktree: identity stable, label follows ───────
|
||||
git -C "$wt" checkout -q -b "another/branch-name"
|
||||
"$wt/scripts/mobile-worktree-overrides.sh" > /dev/null
|
||||
grep -q '^BUNDLE_IDENTIFIER = com\.buzz\.buzzMobile\.feature-work-1$' "$ios" \
|
||||
&& grep -q '^applicationIdSuffix=\.feature_work_1$' "$android" \
|
||||
&& pass "branch switch keeps the install identity stable (per worktree)" \
|
||||
|| fail "install identity must not change on branch switch"
|
||||
grep -q '^label=branch-name$' "$android" \
|
||||
&& pass "branch switch updates the display label" \
|
||||
|| fail "display label must follow the branch, got: $(cat "$android")"
|
||||
|
||||
# ── Apostrophes / exotic-but-valid refs: label is sanitized ──────────────────
|
||||
git -C "$wt" checkout -q -b "it's-\$a\"branch"
|
||||
"$wt/scripts/mobile-worktree-overrides.sh" > /dev/null
|
||||
grep -q "^label=it-s-a-branch$" "$android" \
|
||||
&& pass "apostrophes and shell metacharacters are sanitized out of the label" \
|
||||
|| fail "label must sanitize special chars, got: $(cat "$android")"
|
||||
grep -Eq "^APP_DISPLAY_NAME = Buzz \([A-Za-z0-9._-]+\)$" "$ios" \
|
||||
&& pass "iOS display name only contains resource-safe characters" \
|
||||
|| fail "iOS display name has unsafe characters: $(cat "$ios")"
|
||||
|
||||
# ── Detached HEAD: label falls back to the short SHA ──────────────────────────
|
||||
sha="$(git -C "$wt" rev-parse --short HEAD)"
|
||||
git -C "$wt" checkout -q --detach
|
||||
"$wt/scripts/mobile-worktree-overrides.sh" > /dev/null
|
||||
grep -q "^label=${sha}$" "$android" \
|
||||
&& pass "detached HEAD labels with the short SHA instead of literal HEAD" \
|
||||
|| fail "detached HEAD must use short SHA, got: $(cat "$android")"
|
||||
grep -q '^applicationIdSuffix=\.feature_work_1$' "$android" \
|
||||
&& pass "detached HEAD keeps the per-worktree install identity" \
|
||||
|| fail "detached HEAD must not change the install identity"
|
||||
|
||||
# ── Digit-leading worktree dir gets a letter-prefixed Android segment ────────
|
||||
wt2="$tmp/2fast"
|
||||
make_worktree "$repo" "$wt2" "some-branch"
|
||||
"$wt2/scripts/mobile-worktree-overrides.sh" > /dev/null
|
||||
grep -q '^applicationIdSuffix=\.w_2fast$' "$wt2/mobile/android/worktree.properties" \
|
||||
&& pass "digit-leading worktree dir yields a valid Android package segment" \
|
||||
|| fail "digit-leading dir segment wrong: $(cat "$wt2/mobile/android/worktree.properties")"
|
||||
|
||||
# ── Tracked build files: overrides are debug-only, release stays production ──
|
||||
debug_xcconfig="$repo_root/mobile/ios/Flutter/Debug.xcconfig"
|
||||
release_xcconfig="$repo_root/mobile/ios/Flutter/Release.xcconfig"
|
||||
gradle="$repo_root/mobile/android/app/build.gradle.kts"
|
||||
manifest="$repo_root/mobile/android/app/src/main/AndroidManifest.xml"
|
||||
plist="$repo_root/mobile/ios/Runner/Info.plist"
|
||||
|
||||
grep -q 'WorktreeOverrides.xcconfig' "$debug_xcconfig" \
|
||||
&& pass "Debug.xcconfig includes WorktreeOverrides" \
|
||||
|| fail "Debug.xcconfig must include WorktreeOverrides.xcconfig"
|
||||
worktree_line=$(grep -n 'WorktreeOverrides.xcconfig' "$debug_xcconfig" | cut -d: -f1 | head -1)
|
||||
app_line=$(grep -n 'AppOverrides.xcconfig' "$debug_xcconfig" | grep '#include' | cut -d: -f1 | tail -1)
|
||||
if [[ -n "$worktree_line" && -n "$app_line" && "$worktree_line" -lt "$app_line" ]]; then
|
||||
pass "AppOverrides is included after WorktreeOverrides (developer overrides win)"
|
||||
else
|
||||
fail "Debug.xcconfig must include AppOverrides.xcconfig after WorktreeOverrides.xcconfig"
|
||||
fi
|
||||
grep -q 'WorktreeOverrides' "$release_xcconfig" \
|
||||
&& fail "Release.xcconfig must not include WorktreeOverrides.xcconfig" \
|
||||
|| pass "Release.xcconfig does not include WorktreeOverrides"
|
||||
grep -q '^BUNDLE_IDENTIFIER = com\.buzz\.buzzMobile$' "$release_xcconfig" \
|
||||
&& pass "Release.xcconfig keeps the production bundle identifier" \
|
||||
|| fail "Release.xcconfig must keep BUNDLE_IDENTIFIER = com.buzz.buzzMobile"
|
||||
grep -q '^APP_DISPLAY_NAME = Buzz$' "$release_xcconfig" \
|
||||
&& pass "Release.xcconfig keeps the production display name" \
|
||||
|| fail "Release.xcconfig must keep APP_DISPLAY_NAME = Buzz"
|
||||
grep -q '<string>$(APP_DISPLAY_NAME)</string>' "$plist" \
|
||||
&& pass "Info.plist display name resolves from build settings" \
|
||||
|| fail "Info.plist CFBundleDisplayName must be \$(APP_DISPLAY_NAME)"
|
||||
grep -q 'android:label="@string/app_name"' "$manifest" \
|
||||
&& pass "Android manifest label resolves from resources" \
|
||||
|| fail "Android manifest label must be @string/app_name"
|
||||
grep -q 'resValue("string", "app_name", "Buzz")' "$gradle" \
|
||||
&& pass "Gradle default app_name stays Buzz" \
|
||||
|| fail "Gradle must declare the default app_name resValue"
|
||||
grep -q 'worktreeLabel.matches' "$gradle" \
|
||||
&& pass "Gradle validates the worktree label before use" \
|
||||
|| fail "Gradle must validate the worktree label against a safe pattern"
|
||||
|
||||
# Extract a brace-balanced block: everything from the first line matching $2
|
||||
# to the line where its braces close. Unlike a /start/,/}/ awk range, nested
|
||||
# blocks cannot end the scan early.
|
||||
extract_block() {
|
||||
# $1: file (or - for stdin), $2: start regex
|
||||
awk -v start="$2" '
|
||||
!in_block && $0 ~ start { in_block = 1 }
|
||||
in_block {
|
||||
print
|
||||
depth += gsub(/\{/, "{") - gsub(/\}/, "}")
|
||||
if (depth <= 0) exit
|
||||
}
|
||||
' "$1"
|
||||
}
|
||||
|
||||
# Self-test: the extractor must see past a nested block — this is exactly the
|
||||
# hole the old /release \{/,/\}/ range had.
|
||||
sneaky=$'buildTypes {\n release {\n if (nested) {\n x = 1\n }\n worktreeSneakyReference()\n }\n}'
|
||||
printf '%s\n' "$sneaky" | extract_block - 'release \{' | grep -q 'worktreeSneakyReference' \
|
||||
&& pass "release-block extractor scans past nested braces" \
|
||||
|| fail "release-block extractor must not stop at the first nested close brace"
|
||||
|
||||
# The worktree suffix/label must only appear inside the debug build type.
|
||||
extract_block "$gradle" 'buildTypes \{' | extract_block - 'release \{' | grep -q 'worktree' \
|
||||
&& fail "release build type must not reference worktree identity" \
|
||||
|| pass "release build type does not reference worktree identity"
|
||||
|
||||
git -C "$repo_root" check-ignore -q mobile/ios/Flutter/WorktreeOverrides.xcconfig \
|
||||
&& pass "iOS override file is gitignored" \
|
||||
|| fail "mobile/ios/Flutter/WorktreeOverrides.xcconfig must be gitignored"
|
||||
git -C "$repo_root" check-ignore -q mobile/android/worktree.properties \
|
||||
&& pass "Android override file is gitignored" \
|
||||
|| fail "mobile/android/worktree.properties must be gitignored"
|
||||
grep -Eq '^\s+\./scripts/mobile-worktree-overrides\.sh$' "$repo_root/Justfile" \
|
||||
&& pass "just mobile-dev applies the worktree identity" \
|
||||
|| fail "Justfile mobile-dev must run scripts/mobile-worktree-overrides.sh"
|
||||
grep -Eq '^\s+\./scripts/mobile-worktree-clean\.sh$' "$repo_root/Justfile" \
|
||||
&& pass "just mobile-clean is wired to the cleanup script" \
|
||||
|| fail "Justfile mobile-clean must run scripts/mobile-worktree-clean.sh"
|
||||
|
||||
# ── Cleanup safety: suffixed installs matched, production ids preserved ──────
|
||||
stub_bin="$tmp/stub-bin"
|
||||
mkdir -p "$stub_bin"
|
||||
cat > "$stub_bin/adb" <<'STUB'
|
||||
#!/usr/bin/env bash
|
||||
case "$1 $2" in
|
||||
"devices ") printf 'List of devices attached\nemulator-5554\tdevice\n' ;;
|
||||
esac
|
||||
if [[ "$1" == "devices" ]]; then exit 0; fi
|
||||
if [[ "$3 $4 $5" == "shell pm list" ]]; then
|
||||
printf 'package:xyz.block.buzz.mobile\n'
|
||||
printf 'package:xyz.block.buzz.mobile.feature_work_1\n'
|
||||
printf 'package:xyz.block.buzz.mobile.w_2fast\n'
|
||||
printf 'package:com.android.settings\n'
|
||||
exit 0
|
||||
fi
|
||||
if [[ "$3" == "uninstall" ]]; then echo Success; exit 0; fi
|
||||
exit 0
|
||||
STUB
|
||||
chmod +x "$stub_bin/adb"
|
||||
# No xcrun stub: the iOS pass is skipped when xcrun is absent, which also
|
||||
# keeps this test honest on Linux CI.
|
||||
clean_out="$(PATH="$stub_bin:/usr/bin:/bin" bash "$clean_script" --dry-run)"
|
||||
printf '%s\n' "$clean_out" | grep -q 'xyz\.block\.buzz\.mobile\.feature_work_1' \
|
||||
&& pass "cleanup targets worktree-suffixed Android installs" \
|
||||
|| fail "cleanup must list suffixed installs, got: $clean_out"
|
||||
printf '%s\n' "$clean_out" | grep -q 'xyz\.block\.buzz\.mobile\.w_2fast' \
|
||||
&& pass "cleanup targets letter-prefixed suffixed installs" \
|
||||
|| fail "cleanup must list w_-prefixed installs, got: $clean_out"
|
||||
printf '%s\n' "$clean_out" | grep -q 'mobile\.feature_work_1' || true
|
||||
if printf '%s\n' "$clean_out" | grep -Eq '(would uninstall|uninstalling).*xyz\.block\.buzz\.mobile$'; then
|
||||
fail "cleanup must never target the production Android app id"
|
||||
else
|
||||
pass "cleanup preserves the production Android app id"
|
||||
fi
|
||||
if printf '%s\n' "$clean_out" | grep -q 'com\.android\.settings'; then
|
||||
fail "cleanup must never target unrelated packages"
|
||||
else
|
||||
pass "cleanup ignores unrelated packages"
|
||||
fi
|
||||
printf '%s\n' "$clean_out" | grep -q 'dry run:' \
|
||||
&& pass "cleanup --dry-run reports without uninstalling" \
|
||||
|| fail "cleanup --dry-run must report a dry-run summary, got: $clean_out"
|
||||
|
||||
if [[ "$failures" -gt 0 ]]; then
|
||||
printf '%d failure(s)\n' "$failures" >&2
|
||||
exit 1
|
||||
fi
|
||||
printf 'all mobile worktree identity contract checks passed\n'
|
||||
@@ -0,0 +1,176 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
repo_root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
|
||||
verify="${repo_root}/scripts/verify-release-ref.sh"
|
||||
tmp=$(mktemp -d)
|
||||
trap 'rm -rf "$tmp"' EXIT
|
||||
|
||||
git -C "$tmp" init -q
|
||||
git -C "$tmp" config user.name test
|
||||
git -C "$tmp" config user.email test@example.com
|
||||
echo first >"$tmp/file"
|
||||
git -C "$tmp" add file
|
||||
git -C "$tmp" commit -qm first
|
||||
git -C "$tmp" tag -m "desktop release" desktop-v1.2.3
|
||||
|
||||
(
|
||||
cd "$tmp"
|
||||
GITHUB_REF=refs/tags/desktop-v1.2.3 "$verify" desktop-v 1.2.3
|
||||
)
|
||||
|
||||
if (
|
||||
cd "$tmp"
|
||||
GITHUB_REF=refs/heads/main "$verify" desktop-v 1.2.3
|
||||
); then
|
||||
echo "branch-backed desktop release was accepted" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo second >>"$tmp/file"
|
||||
git -C "$tmp" commit -qam second
|
||||
if (
|
||||
cd "$tmp"
|
||||
GITHUB_REF=refs/tags/desktop-v1.2.3 "$verify" desktop-v 1.2.3
|
||||
); then
|
||||
echo "release accepted HEAD after the tag commit" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
git -C "$tmp" tag -m "relay release" relay-v2.0.0
|
||||
(
|
||||
cd "$tmp"
|
||||
GITHUB_REF=refs/tags/relay-v2.0.0 "$verify" relay-v 2.0.0
|
||||
)
|
||||
|
||||
if grep -q 'inputs\.ref' \
|
||||
"$repo_root/.github/workflows/release.yml" \
|
||||
"$repo_root/.github/workflows/docker.yml"; then
|
||||
echo "publisher workflow still accepts a caller-selected source ref" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
grep -q 'verify-release-ref\.sh' "$repo_root/.github/workflows/release.yml"
|
||||
grep -q 'verify-release-ref\.sh' "$repo_root/.github/workflows/docker.yml"
|
||||
grep -q 'test-release-ref-contract\.sh' "$repo_root/.github/workflows/ci.yml"
|
||||
"$repo_root/scripts/test-signed-canary-contract.sh"
|
||||
"$repo_root/scripts/test-desktop-release-cache-key.sh"
|
||||
"$repo_root/scripts/test-desktop-release-cache-workflow.sh"
|
||||
auto_tag="$repo_root/.github/workflows/auto-tag-on-release-pr-merge.yml"
|
||||
grep -q 'actions/create-github-app-token@' "$auto_tag"
|
||||
grep -q 'client-id:.*vars\.BUZZ_RELEASE_TAGGER_CLIENT_ID' "$auto_tag"
|
||||
grep -q 'private-key:.*secrets\.BUZZ_RELEASE_TAGGER_PRIVATE_KEY' "$auto_tag"
|
||||
grep -q 'permission-contents: write' "$auto_tag"
|
||||
grep -q 'GH_TOKEN:.*steps\.release-tagger\.outputs\.token' "$auto_tag"
|
||||
grep -Fq 'git/refs' "$auto_tag"
|
||||
grep -Fq 'TAG_PREFIX="desktop-v"' "$auto_tag"
|
||||
grep -Fq 'target_sha=${{ github.event.pull_request.head.sha }}' "$auto_tag"
|
||||
grep -Fq 'scripts/verify-desktop-release-merge.sh' "$auto_tag"
|
||||
candidate_workflow="$repo_root/.github/workflows/desktop-release-candidate.yml"
|
||||
grep -Eq '^ pull-requests: read$' "$candidate_workflow" || {
|
||||
echo "desktop candidate token cannot read pull requests for prior-release lookup" >&2
|
||||
exit 1
|
||||
}
|
||||
grep -Fq 'GH_TOKEN: ${{ github.token }}' "$candidate_workflow" || {
|
||||
echo "desktop candidate validation has no GitHub token for prior-release lookup" >&2
|
||||
exit 1
|
||||
}
|
||||
grep -Fq 'reviewed candidate' "$repo_root/scripts/prepare-desktop-release.sh"
|
||||
if grep -Fq 'current `main`' "$repo_root/scripts/prepare-desktop-release.sh"; then
|
||||
echo "desktop release PR body contains executable command substitution" >&2
|
||||
exit 1
|
||||
fi
|
||||
required_check_filter="$repo_root/scripts/required-check-succeeded.jq"
|
||||
check_fixture() {
|
||||
local expected="$1" conclusion="$2" app="${3:-15368}" completed="${4:-2026-01-01T00:00:00Z}"
|
||||
local payload actual
|
||||
# Production-shaped REST check run: notably, there is no created_at field.
|
||||
payload=$(jq -n --arg conclusion "$conclusion" --argjson app "$app" --arg completed "$completed" \
|
||||
'{check_runs: [{id: 100, check_suite: {id: 10}, name: "Web", app: {id: $app}, status: "completed", conclusion: $conclusion, started_at: "2026-01-01T00:00:00Z", completed_at: $completed}]}')
|
||||
if jq -e --arg name Web --argjson integration_id 15368 \
|
||||
--arg merged_at 2026-01-02T00:00:00Z \
|
||||
-f "$required_check_filter" <<<"[$payload]" >/dev/null; then actual=pass; else actual=fail; fi
|
||||
[[ "$actual" == "$expected" ]] || { echo "required-check fixture expected $expected, got $actual" >&2; exit 1; }
|
||||
}
|
||||
check_fixture pass success
|
||||
check_fixture pass skipped
|
||||
check_fixture pass neutral
|
||||
check_fixture fail failure
|
||||
check_fixture fail success 999
|
||||
check_fixture fail success 15368 2026-01-03T00:00:00Z
|
||||
|
||||
# filter=latest may still return multiple same-name runs from distinct workflows.
|
||||
# Highest immutable run ID is authoritative and must not reveal stale green.
|
||||
jq -e --arg name Web --argjson integration_id 15368 --arg merged_at 2026-01-02T00:00:00Z \
|
||||
-f "$required_check_filter" >/dev/null <<'JSON' && {
|
||||
[{"check_runs":[
|
||||
{"id":100,"check_suite":{"id":10},"name":"Web","app":{"id":15368},"status":"completed","conclusion":"success","started_at":"2026-01-01T00:00:00Z","completed_at":"2026-01-01T01:00:00Z"},
|
||||
{"id":101,"check_suite":{"id":11},"name":"Web","app":{"id":15368},"status":"in_progress","conclusion":null,"started_at":"2026-01-01T23:59:00Z","completed_at":null}
|
||||
]}]
|
||||
JSON
|
||||
echo "required-check filter hid the highest-ID pending attempt" >&2; exit 1;
|
||||
}
|
||||
# A post-merge rerun is indistinguishable from other latest attempts and fails closed.
|
||||
jq -e --arg name Web --argjson integration_id 15368 --arg merged_at 2026-01-02T00:00:00Z \
|
||||
-f "$required_check_filter" >/dev/null <<'JSON' && {
|
||||
[{"check_runs":[
|
||||
{"id":100,"check_suite":{"id":10},"name":"Web","app":{"id":15368},"status":"completed","conclusion":"success","started_at":"2026-01-01T00:00:00Z","completed_at":"2026-01-01T01:00:00Z"},
|
||||
{"id":101,"check_suite":{"id":10},"name":"Web","app":{"id":15368},"status":"completed","conclusion":"failure","started_at":"2026-01-02T00:01:00Z","completed_at":"2026-01-02T00:10:00Z"}
|
||||
]}]
|
||||
JSON
|
||||
echo "required-check filter accepted stale success after post-merge rerun" >&2; exit 1;
|
||||
}
|
||||
# DCO alone may complete just after merge, inside its explicit five-minute bound.
|
||||
dco_fixture() {
|
||||
local expected="$1" completed="$2" actual
|
||||
if jq -e --arg name "DCO Check" --argjson integration_id 1455659 --arg merged_at 2026-01-02T00:00:00Z \
|
||||
-f "$required_check_filter" >/dev/null <<JSON
|
||||
[{"check_runs":[{"id":200,"check_suite":{"id":20},"name":"DCO Check","app":{"id":1455659},"status":"completed","conclusion":"success","started_at":"$completed","completed_at":"$completed"}]}]
|
||||
JSON
|
||||
then actual=pass; else actual=fail; fi
|
||||
[[ "$actual" == "$expected" ]] || { echo "DCO completion $completed expected $expected" >&2; exit 1; }
|
||||
}
|
||||
dco_fixture pass 2026-01-02T00:04:59Z
|
||||
dco_fixture fail 2026-01-02T00:05:01Z
|
||||
|
||||
# The verifier must request production endpoint semantics and pin helpers before checkout.
|
||||
verify_merge="$repo_root/scripts/verify-desktop-release-merge.sh"
|
||||
grep -Fq 'check-runs?filter=latest&per_page=100' "$verify_merge"
|
||||
grep -Fq 'git fetch origin main --no-tags' "$verify_merge"
|
||||
grep -Fq 'git merge-base --is-ancestor "$candidate_parents" origin/main' "$verify_merge"
|
||||
grep -Fq 'git show "$candidate_parents:scripts/desktop_release.py"' "$verify_merge"
|
||||
grep -Fq 'git show "$candidate_parents:scripts/required-check-succeeded.jq"' "$verify_merge"
|
||||
grep -Fq 'DESKTOP_RELEASE_ROOT="$PWD" python3 "$verifier_dir/desktop_release.py"' "$verify_merge"
|
||||
grep -Fq -- '-f "$verifier_dir/required-check-succeeded.jq"' "$verify_merge"
|
||||
|
||||
release_workflow="$repo_root/.github/workflows/release.yml"
|
||||
[[ "$(grep -c 'contents: write' "$release_workflow")" -eq 1 ]] || {
|
||||
echo "desktop release must have exactly one GitHub contents writer" >&2; exit 1;
|
||||
}
|
||||
grep -Fq "needs.release.result == 'success'" "$release_workflow"
|
||||
grep -Fq "needs.release-macos-x64.result == 'success'" "$release_workflow"
|
||||
grep -Fq "needs.release-linux.result == 'success'" "$release_workflow"
|
||||
grep -Fq "needs.release-windows.result == 'success'" "$release_workflow"
|
||||
grep -Fq "refs/tags/desktop-v{0}" "$release_workflow"
|
||||
grep -Fq "if: \${{ !contains(needs.setup.outputs.version, '-') }}" "$release_workflow"
|
||||
if grep -Fq "env.already_published != 'true' && !contains(needs.setup.outputs.version, '-')" "$release_workflow"; then
|
||||
echo "rolling updater retry is incorrectly gated by versioned publication state" >&2; exit 1
|
||||
fi
|
||||
grep -Fq 'group: desktop-release-${{ github.ref }}' "$release_workflow"
|
||||
grep -Fq 'cancel-in-progress: false' "$release_workflow"
|
||||
grep -Fq 'release artifact basename collision' "$release_workflow"
|
||||
[[ "$(grep -c 'gh release upload' "$release_workflow")" -eq 2 ]] || {
|
||||
echo "only the final writer may upload versioned and rolling release assets" >&2; exit 1;
|
||||
}
|
||||
grep -Fq 'if: env.already_published' "$release_workflow"
|
||||
grep -Fq 'if gh api "repos/$GITHUB_REPOSITORY/git/ref/tags/$TAG" --silent 2>/dev/null; then' "$auto_tag"
|
||||
if grep -F 'git/ref/tags/$TAG' "$auto_tag" | grep -Fq '|| true'; then
|
||||
echo "auto-tag ignores a failed tag lookup, so a 404 body can look like an existing tag" >&2
|
||||
exit 1
|
||||
fi
|
||||
if grep -q 'gh workflow run' "$auto_tag"; then
|
||||
echo "auto-tag still dispatches a publisher instead of using the tag push" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "release ref contract passed"
|
||||
@@ -0,0 +1,38 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
repo_root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
|
||||
tmp=$(mktemp -d)
|
||||
trap 'rm -rf "$tmp"' EXIT
|
||||
export HOME="$tmp/home"
|
||||
export BUZZ_TEST_PLATFORM=Darwin
|
||||
mkdir -p "$HOME/Library/Application Support/xyz.block.buzz.app.dev.example"
|
||||
mkdir -p "$HOME/Library/Application Support/xyz.block.buzz.app.dev.other"
|
||||
mkdir -p "$HOME/Library/Application Support/xyz.block.buzz.app"
|
||||
mkdir -p "$HOME/.buzz-dev"
|
||||
touch "$HOME/.buzz-dev/keep"
|
||||
mkdir -p "$tmp/bin"
|
||||
cat > "$tmp/bin/security" <<'MOCK'
|
||||
#!/usr/bin/env bash
|
||||
printf '%s\n' "$*" >> "$HOME/security-calls"
|
||||
exit 1
|
||||
MOCK
|
||||
chmod +x "$tmp/bin/security"
|
||||
export PATH="$tmp/bin:$PATH"
|
||||
|
||||
"$repo_root/scripts/reset-desktop-standalone-state.sh" \
|
||||
xyz.block.buzz.app.dev.example buzz-desktop-dev.example
|
||||
|
||||
[[ ! -e "$HOME/Library/Application Support/xyz.block.buzz.app.dev.example" ]]
|
||||
[[ -d "$HOME/Library/Application Support/xyz.block.buzz.app.dev.other" ]]
|
||||
[[ -d "$HOME/Library/Application Support/xyz.block.buzz.app" ]]
|
||||
[[ -f "$HOME/.buzz-dev/keep" ]]
|
||||
grep -Fx -- "delete-generic-password -s buzz-desktop-dev.example" "$HOME/security-calls" >/dev/null
|
||||
|
||||
if "$repo_root/scripts/reset-desktop-standalone-state.sh" \
|
||||
xyz.block.buzz.app buzz-desktop >/dev/null 2>&1; then
|
||||
echo "expected production scope guard to reject reset" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "standalone desktop reset scope test passed"
|
||||
@@ -0,0 +1,40 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
repo_root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
|
||||
workflow="$repo_root/.github/workflows/signed-macos-canary.yml"
|
||||
|
||||
grep -Fq 'workflow_dispatch:' "$workflow"
|
||||
# The literal GitHub expression is the contract we are checking.
|
||||
# shellcheck disable=SC2016
|
||||
grep -Fq 'SOURCE_REF: ${{ github.ref }}' "$workflow"
|
||||
grep -Fq '"refs/heads/main"' "$workflow"
|
||||
grep -Fq 'contents: read' "$workflow"
|
||||
grep -Fq 'id-token: write' "$workflow"
|
||||
grep -Fq 'block/apple-codesign-action@' "$workflow"
|
||||
grep -Fq 'actions/upload-artifact@' "$workflow"
|
||||
grep -Fq 'retention-days: 7' "$workflow"
|
||||
grep -Fq '"createUpdaterArtifacts": false' "$workflow"
|
||||
|
||||
if grep -Eq 'contents: write|gh release|buzz-desktop-latest|latest\.json|TAURI_SIGNING_PRIVATE_KEY|verify-release-ref\.sh|refs/tags/' "$workflow"; then
|
||||
echo "signed canary workflow gained a release or publishing capability" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
on_block=$(
|
||||
awk '
|
||||
/^on:$/ { in_on = 1; next }
|
||||
in_on && /^[^[:space:]#]/ { exit }
|
||||
in_on && NF && $0 !~ /^[[:space:]]*#/ {
|
||||
gsub(/[[:space:]]/, "")
|
||||
print
|
||||
}
|
||||
' "$workflow"
|
||||
)
|
||||
if [[ "$on_block" != "workflow_dispatch:" ]]; then
|
||||
echo "signed canary workflow must have workflow_dispatch as its only trigger" >&2
|
||||
printf 'found on block:\n%s\n' "$on_block" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "signed canary contract passed"
|
||||
@@ -0,0 +1,43 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
IMAGE="${1:-buzz-sprig:contract-test}"
|
||||
if [[ "${SKIP_BUILD:-0}" != 1 ]]; then
|
||||
docker build --file Dockerfile.sprig --tag "$IMAGE" .
|
||||
fi
|
||||
|
||||
assert_run() {
|
||||
docker run --rm --entrypoint /bin/bash "$IMAGE" -ceu "$1"
|
||||
}
|
||||
|
||||
assert_run '
|
||||
command -v bash git update-ca-certificates >/dev/null
|
||||
test "$(readlink /usr/local/bin/buzz-acp)" = sprig
|
||||
for name in buzz-agent buzz-dev-mcp rg tree buzz git-credential-nostr git-sign-nostr; do
|
||||
test "$(readlink "/usr/local/bin/$name")" = sprig
|
||||
done
|
||||
test "$(git config --system gpg.x509.program)" = /usr/local/bin/git-sign-nostr
|
||||
! git config --system --get-all credential.helper
|
||||
test "$HOME" = /home/agent
|
||||
test "$(pwd)" = /home/agent
|
||||
'
|
||||
|
||||
assert_run '
|
||||
grep -Eq "^[[:space:]]*exec buzz-acp" /usr/local/bin/sprig-entrypoint
|
||||
! grep -Eq "^[[:space:]]*(buzz-acp|bash -c .*buzz-acp)" /usr/local/bin/sprig-entrypoint
|
||||
'
|
||||
|
||||
docker run --rm --entrypoint /bin/bash \
|
||||
-e BUZZ_RELAY_URL=wss://relay.example.test/ "$IMAGE" -ceu '
|
||||
/usr/local/bin/sprig-entrypoint --help >/dev/null 2>&1 & pid=$!
|
||||
for _ in 1 2 3 4 5; do
|
||||
git config --global --get credential.https://relay.example.test/git.helper >/dev/null 2>&1 && break
|
||||
sleep 0.1
|
||||
done
|
||||
test "$(git config --global --get credential.https://relay.example.test/git.helper)" = /usr/local/bin/git-credential-nostr
|
||||
test "$(git config --global --get credential.https://relay.example.test/git.useHttpPath)" = true
|
||||
! git config --global --get-all credential.helper
|
||||
wait "$pid" || true
|
||||
'
|
||||
|
||||
echo "PASS: Sprig image runtime contract ($IMAGE)"
|
||||
@@ -0,0 +1,378 @@
|
||||
#!/usr/bin/env bash
|
||||
# test-video-upload.sh — Live validation of the Blossom video upload flow.
|
||||
#
|
||||
# Prerequisites:
|
||||
# - Relay running at $RELAY_URL (default: http://localhost:3000)
|
||||
# - Dev mode (BUZZ_REQUIRE_AUTH_TOKEN=false) or valid API token
|
||||
# - ffmpeg, nak, curl, jq, shasum on PATH
|
||||
#
|
||||
# Usage:
|
||||
# ./scripts/test-video-upload.sh # run all tests
|
||||
# RELAY_URL=http://host:3000 ./scripts/... # custom relay URL
|
||||
# NSEC=nsec1... ./scripts/... # use existing key
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
RELAY_URL="${RELAY_URL:-http://localhost:3000}"
|
||||
TMPDIR="$(mktemp -d)"
|
||||
trap 'rm -rf "$TMPDIR"' EXIT
|
||||
|
||||
PASS=0
|
||||
FAIL=0
|
||||
|
||||
pass() { echo " ✅ $1"; PASS=$((PASS + 1)); }
|
||||
fail() { echo " ❌ $1"; FAIL=$((FAIL + 1)); }
|
||||
|
||||
# ── Dependencies ───────────────────────────────────────────────────────────────
|
||||
|
||||
for cmd in ffmpeg nak curl jq shasum; do
|
||||
command -v "$cmd" >/dev/null 2>&1 || { echo "Missing: $cmd"; exit 1; }
|
||||
done
|
||||
|
||||
# ── Key generation ─────────────────────────────────────────────────────────────
|
||||
|
||||
if [ -z "${NSEC:-}" ]; then
|
||||
NSEC="$(nak key generate)"
|
||||
echo "Generated key: $(echo "$NSEC" | nak key public)"
|
||||
fi
|
||||
NPUB="$(echo "$NSEC" | nak key public)"
|
||||
echo "Using pubkey: $NPUB"
|
||||
echo "Relay: $RELAY_URL"
|
||||
echo ""
|
||||
|
||||
# ── Generate test MP4 ─────────────────────────────────────────────────────────
|
||||
# Minimal 1-second H.264 video with moov at front (faststart).
|
||||
|
||||
TEST_MP4="$TMPDIR/test.mp4"
|
||||
ffmpeg -y -f lavfi -i "color=c=blue:s=320x240:d=1" \
|
||||
-c:v libx264 -profile:v baseline -pix_fmt yuv420p \
|
||||
-movflags +faststart \
|
||||
"$TEST_MP4" 2>/dev/null
|
||||
|
||||
FILE_SIZE=$(wc -c < "$TEST_MP4" | tr -d ' ')
|
||||
SHA256=$(shasum -a 256 "$TEST_MP4" | cut -d' ' -f1)
|
||||
echo "Test MP4: ${FILE_SIZE} bytes, sha256=${SHA256:0:16}..."
|
||||
echo ""
|
||||
|
||||
# ── Helper: build Blossom auth header ──────────────────────────────────────────
|
||||
# Creates a kind:24242 event with t=upload, x=<sha256>, expiration=+5min.
|
||||
|
||||
blossom_auth() {
|
||||
local sha256="$1"
|
||||
local now exp auth_event auth_b64
|
||||
|
||||
now=$(date +%s)
|
||||
exp=$((now + 300))
|
||||
|
||||
auth_event=$(nak event \
|
||||
--sec "$NSEC" \
|
||||
-k 24242 \
|
||||
-c "Upload test video" \
|
||||
-t t=upload \
|
||||
-t "x=$sha256" \
|
||||
-t "expiration=$exp" \
|
||||
2>/dev/null)
|
||||
|
||||
auth_b64=$(echo -n "$auth_event" | base64 | tr -d '\n')
|
||||
echo "Nostr $auth_b64"
|
||||
}
|
||||
|
||||
# ── Test 1: Upload MP4 ────────────────────────────────────────────────────────
|
||||
|
||||
echo "Test 1: Upload MP4 via PUT /media/upload"
|
||||
AUTH="$(blossom_auth "$SHA256")"
|
||||
|
||||
UPLOAD_RESP=$(curl -s -w "\n%{http_code}" \
|
||||
-X PUT "$RELAY_URL/media/upload" \
|
||||
-H "Authorization: $AUTH" \
|
||||
-H "Content-Type: video/mp4" \
|
||||
-H "X-SHA-256: $SHA256" \
|
||||
--data-binary "@$TEST_MP4")
|
||||
|
||||
UPLOAD_HTTP=$(echo "$UPLOAD_RESP" | tail -1)
|
||||
UPLOAD_BODY=$(echo "$UPLOAD_RESP" | sed '$d')
|
||||
|
||||
if [ "$UPLOAD_HTTP" = "200" ]; then
|
||||
pass "Upload returned 200"
|
||||
BLOB_URL=$(echo "$UPLOAD_BODY" | jq -r '.url // empty')
|
||||
if [ -n "$BLOB_URL" ]; then
|
||||
pass "Response contains url: ${BLOB_URL:0:60}..."
|
||||
else
|
||||
fail "Response missing url field"
|
||||
fi
|
||||
# Check duration field present
|
||||
DURATION=$(echo "$UPLOAD_BODY" | jq -r '.duration // empty')
|
||||
if [ -n "$DURATION" ]; then
|
||||
pass "Response contains duration: ${DURATION}s"
|
||||
else
|
||||
fail "Response missing duration field"
|
||||
fi
|
||||
else
|
||||
fail "Upload returned $UPLOAD_HTTP (expected 200)"
|
||||
echo " Body: $UPLOAD_BODY"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# ── Test 2: GET full blob ─────────────────────────────────────────────────────
|
||||
|
||||
echo "Test 2: GET /media/${SHA256}.mp4 (full download)"
|
||||
GET_RESP=$(curl -s -o "$TMPDIR/downloaded.mp4" -w "%{http_code}" \
|
||||
"$RELAY_URL/media/${SHA256}.mp4")
|
||||
|
||||
if [ "$GET_RESP" = "200" ]; then
|
||||
pass "GET returned 200"
|
||||
DL_SIZE=$(wc -c < "$TMPDIR/downloaded.mp4" | tr -d ' ')
|
||||
if [ "$DL_SIZE" = "$FILE_SIZE" ]; then
|
||||
pass "Downloaded size matches ($DL_SIZE bytes)"
|
||||
else
|
||||
fail "Size mismatch: expected $FILE_SIZE, got $DL_SIZE"
|
||||
fi
|
||||
else
|
||||
fail "GET returned $GET_RESP (expected 200)"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# ── Test 3: HEAD with Accept-Ranges ──────────────────────────────────────────
|
||||
|
||||
echo "Test 3: HEAD /media/${SHA256}.mp4 (Accept-Ranges)"
|
||||
HEAD_RESP=$(curl -s -I "$RELAY_URL/media/${SHA256}.mp4")
|
||||
HEAD_HTTP=$(echo "$HEAD_RESP" | head -1 | grep -o '[0-9]\{3\}')
|
||||
ACCEPT_RANGES=$(echo "$HEAD_RESP" | grep -i "accept-ranges" | tr -d '\r')
|
||||
|
||||
if [ "$HEAD_HTTP" = "200" ]; then
|
||||
pass "HEAD returned 200"
|
||||
else
|
||||
fail "HEAD returned $HEAD_HTTP (expected 200)"
|
||||
fi
|
||||
|
||||
if echo "$ACCEPT_RANGES" | grep -qi "bytes"; then
|
||||
pass "Accept-Ranges: bytes present"
|
||||
else
|
||||
fail "Accept-Ranges header missing or wrong: '$ACCEPT_RANGES'"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# ── Test 4: Range GET (206 Partial Content) ──────────────────────────────────
|
||||
|
||||
echo "Test 4: Range GET bytes=0-499 (206 Partial Content)"
|
||||
RANGE_RESP=$(curl -s -o "$TMPDIR/range.bin" -w "%{http_code}" \
|
||||
-H "Range: bytes=0-499" \
|
||||
"$RELAY_URL/media/${SHA256}.mp4")
|
||||
|
||||
if [ "$RANGE_RESP" = "206" ]; then
|
||||
pass "Range GET returned 206"
|
||||
RANGE_SIZE=$(wc -c < "$TMPDIR/range.bin" | tr -d ' ')
|
||||
if [ "$RANGE_SIZE" = "500" ]; then
|
||||
pass "Received exactly 500 bytes"
|
||||
else
|
||||
fail "Expected 500 bytes, got $RANGE_SIZE"
|
||||
fi
|
||||
else
|
||||
fail "Range GET returned $RANGE_RESP (expected 206)"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# ── Test 5: Range GET past EOF (416) ─────────────────────────────────────────
|
||||
|
||||
echo "Test 5: Range GET bytes=999999999- (416 Range Not Satisfiable)"
|
||||
RANGE416_RESP=$(curl -s -o /dev/null -w "%{http_code}" \
|
||||
-H "Range: bytes=999999999-" \
|
||||
"$RELAY_URL/media/${SHA256}.mp4")
|
||||
|
||||
if [ "$RANGE416_RESP" = "416" ]; then
|
||||
pass "Past-EOF range returned 416"
|
||||
else
|
||||
fail "Past-EOF range returned $RANGE416_RESP (expected 416)"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# ── Test 6: Content-Type spoofing rejection ──────────────────────────────────
|
||||
# Send video/mp4 Content-Type but with a PNG body — should be rejected.
|
||||
|
||||
echo "Test 6: Content-Type spoofing (video/mp4 header, PNG body)"
|
||||
PNG_FILE="$TMPDIR/fake.png"
|
||||
# Minimal valid PNG (1x1 red pixel)
|
||||
printf '\x89PNG\r\n\x1a\n' > "$PNG_FILE"
|
||||
dd if=/dev/zero bs=100 count=1 >> "$PNG_FILE" 2>/dev/null
|
||||
|
||||
PNG_SHA=$(shasum -a 256 "$PNG_FILE" | cut -d' ' -f1)
|
||||
SPOOF_AUTH="$(blossom_auth "$PNG_SHA")"
|
||||
|
||||
SPOOF_RESP=$(curl -s -w "\n%{http_code}" \
|
||||
-X PUT "$RELAY_URL/media/upload" \
|
||||
-H "Authorization: $SPOOF_AUTH" \
|
||||
-H "Content-Type: video/mp4" \
|
||||
-H "X-SHA-256: $PNG_SHA" \
|
||||
--data-binary "@$PNG_FILE")
|
||||
|
||||
SPOOF_HTTP=$(echo "$SPOOF_RESP" | tail -1)
|
||||
|
||||
if [ "$SPOOF_HTTP" = "415" ] || [ "$SPOOF_HTTP" = "400" ]; then
|
||||
pass "Spoofed upload rejected with $SPOOF_HTTP"
|
||||
else
|
||||
fail "Spoofed upload returned $SPOOF_HTTP (expected 400 or 415)"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# ── Test 7: Idempotent re-upload ─────────────────────────────────────────────
|
||||
|
||||
echo "Test 7: Idempotent re-upload (same file, same hash)"
|
||||
REUP_AUTH="$(blossom_auth "$SHA256")"
|
||||
REUP_RESP=$(curl -s -w "\n%{http_code}" \
|
||||
-X PUT "$RELAY_URL/media/upload" \
|
||||
-H "Authorization: $REUP_AUTH" \
|
||||
-H "Content-Type: video/mp4" \
|
||||
-H "X-SHA-256: $SHA256" \
|
||||
--data-binary "@$TEST_MP4")
|
||||
|
||||
REUP_HTTP=$(echo "$REUP_RESP" | tail -1)
|
||||
if [ "$REUP_HTTP" = "200" ]; then
|
||||
pass "Re-upload returned 200 (idempotent)"
|
||||
else
|
||||
fail "Re-upload returned $REUP_HTTP (expected 200)"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# ── Test 8: Poster frame upload + imeta validation ───────────────────────────
|
||||
# Upload a JPEG poster frame, then verify the server accepts an imeta tag
|
||||
# that links the video and poster via the NIP-71 `image` field.
|
||||
|
||||
echo "Test 8: Upload poster frame (JPEG image)"
|
||||
POSTER_JPG="$TMPDIR/poster.jpg"
|
||||
ffmpeg -y -ss 0.5 -i "$TEST_MP4" -vframes 1 -vf "scale=640:-2" -q:v 2 \
|
||||
"$POSTER_JPG" 2>/dev/null
|
||||
|
||||
if [ ! -s "$POSTER_JPG" ]; then
|
||||
# Fallback: first frame (video may be too short for 0.5s seek)
|
||||
ffmpeg -y -i "$TEST_MP4" -vframes 1 -vf "scale=640:-2" -q:v 2 \
|
||||
"$POSTER_JPG" 2>/dev/null
|
||||
fi
|
||||
|
||||
POSTER_SIZE=$(wc -c < "$POSTER_JPG" | tr -d ' ')
|
||||
POSTER_SHA=$(shasum -a 256 "$POSTER_JPG" | cut -d' ' -f1)
|
||||
echo " Poster: ${POSTER_SIZE} bytes, sha256=${POSTER_SHA:0:16}..."
|
||||
|
||||
POSTER_AUTH="$(blossom_auth "$POSTER_SHA")"
|
||||
POSTER_RESP=$(curl -s -w "\n%{http_code}" \
|
||||
-X PUT "$RELAY_URL/media/upload" \
|
||||
-H "Authorization: $POSTER_AUTH" \
|
||||
-H "Content-Type: image/jpeg" \
|
||||
-H "X-SHA-256: $POSTER_SHA" \
|
||||
--data-binary "@$POSTER_JPG")
|
||||
|
||||
POSTER_HTTP=$(echo "$POSTER_RESP" | tail -1)
|
||||
POSTER_BODY=$(echo "$POSTER_RESP" | sed '$d')
|
||||
|
||||
if [ "$POSTER_HTTP" = "200" ]; then
|
||||
pass "Poster upload returned 200"
|
||||
POSTER_URL=$(echo "$POSTER_BODY" | jq -r '.url // empty')
|
||||
if [ -n "$POSTER_URL" ]; then
|
||||
pass "Poster has url: ${POSTER_URL:0:60}..."
|
||||
else
|
||||
fail "Poster response missing url"
|
||||
fi
|
||||
# Poster should have dim but NOT duration
|
||||
POSTER_DIM=$(echo "$POSTER_BODY" | jq -r '.dim // empty')
|
||||
POSTER_DUR=$(echo "$POSTER_BODY" | jq -r '.duration // empty')
|
||||
if [ -n "$POSTER_DIM" ]; then
|
||||
pass "Poster has dim: $POSTER_DIM"
|
||||
else
|
||||
fail "Poster missing dim"
|
||||
fi
|
||||
if [ -z "$POSTER_DUR" ]; then
|
||||
pass "Poster correctly omits duration"
|
||||
else
|
||||
fail "Poster should not have duration, got: $POSTER_DUR"
|
||||
fi
|
||||
else
|
||||
fail "Poster upload returned $POSTER_HTTP (expected 200)"
|
||||
echo " Body: $POSTER_BODY"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# ── Test 9: GET poster frame ─────────────────────────────────────────────────
|
||||
|
||||
echo "Test 9: GET poster frame"
|
||||
POSTER_GET_RESP=$(curl -s -o "$TMPDIR/poster_dl.jpg" -w "%{http_code}" \
|
||||
"$RELAY_URL/media/${POSTER_SHA}.jpg")
|
||||
|
||||
if [ "$POSTER_GET_RESP" = "200" ]; then
|
||||
pass "GET poster returned 200"
|
||||
POSTER_DL_SIZE=$(wc -c < "$TMPDIR/poster_dl.jpg" | tr -d ' ')
|
||||
if [ "$POSTER_DL_SIZE" = "$POSTER_SIZE" ]; then
|
||||
pass "Poster download size matches ($POSTER_DL_SIZE bytes)"
|
||||
else
|
||||
fail "Poster size mismatch: expected $POSTER_SIZE, got $POSTER_DL_SIZE"
|
||||
fi
|
||||
else
|
||||
fail "GET poster returned $POSTER_GET_RESP (expected 200)"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# ── Test 10: Video + poster blobs coexist and are independently retrievable ──
|
||||
# The server links video and poster purely through the imeta tag at message
|
||||
# send time. Here we verify the prerequisite: both blobs exist, have correct
|
||||
# Content-Types, and are independently addressable.
|
||||
|
||||
echo "Test 10: Video + poster blobs coexist"
|
||||
VIDEO_HEAD=$(curl -s -o /dev/null -w "%{http_code}" -I "$RELAY_URL/media/${SHA256}.mp4")
|
||||
POSTER_HEAD=$(curl -s -o /dev/null -w "%{http_code}" -I "$RELAY_URL/media/${POSTER_SHA}.jpg")
|
||||
|
||||
if [ "$VIDEO_HEAD" = "200" ] && [ "$POSTER_HEAD" = "200" ]; then
|
||||
pass "Both video and poster blobs exist (HEAD 200)"
|
||||
else
|
||||
fail "Blob existence check failed: video=$VIDEO_HEAD poster=$POSTER_HEAD"
|
||||
fi
|
||||
|
||||
# Verify poster is an image (not video) by checking Content-Type
|
||||
POSTER_CT=$(curl -s -I "$RELAY_URL/media/${POSTER_SHA}.jpg" | grep -i "content-type" | tr -d '\r' | awk '{print $2}')
|
||||
if echo "$POSTER_CT" | grep -qi "image/jpeg"; then
|
||||
pass "Poster Content-Type is image/jpeg"
|
||||
else
|
||||
fail "Poster Content-Type should be image/jpeg, got: $POSTER_CT"
|
||||
fi
|
||||
|
||||
# Verify video and poster have different hashes (independent blobs)
|
||||
if [ "$SHA256" != "$POSTER_SHA" ]; then
|
||||
pass "Video and poster have distinct content hashes"
|
||||
else
|
||||
fail "Video and poster hashes should differ"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# ── Test 11: Poster sidecar has correct metadata ─────────────────────────────
|
||||
# The server writes a JSON sidecar for every uploaded blob. Verify the poster
|
||||
# sidecar exists and has image MIME type (the server's verify_imeta_blobs
|
||||
# checks this at message send time).
|
||||
|
||||
echo "Test 11: Poster sidecar metadata"
|
||||
# The bare-hash GET resolves via sidecar — if it returns 200 with image/jpeg
|
||||
# content-type, the sidecar is correctly configured.
|
||||
POSTER_BARE_RESP=$(curl -s -D "$TMPDIR/poster_bare_headers.txt" -o /dev/null -w "%{http_code}" \
|
||||
"$RELAY_URL/media/${POSTER_SHA}")
|
||||
if [ "$POSTER_BARE_RESP" = "200" ]; then
|
||||
pass "Poster bare-hash GET resolves via sidecar (200)"
|
||||
else
|
||||
fail "Poster bare-hash GET returned $POSTER_BARE_RESP (expected 200)"
|
||||
fi
|
||||
|
||||
# Verify Content-Type on the bare-hash response
|
||||
POSTER_BARE_CT=$(grep -i "content-type" "$TMPDIR/poster_bare_headers.txt" | tr -d '\r' | awk '{print $2}')
|
||||
if echo "$POSTER_BARE_CT" | grep -qi "image/jpeg"; then
|
||||
pass "Poster bare-hash Content-Type is image/jpeg"
|
||||
else
|
||||
fail "Poster bare-hash Content-Type should be image/jpeg, got: $POSTER_BARE_CT"
|
||||
fi
|
||||
|
||||
# Note: Full imeta image validation (accept/reject at message send time) is
|
||||
# covered by Rust unit tests: test_imeta_image_poster_frame_accepted,
|
||||
# test_imeta_image_video_url_rejected, test_imeta_image_thumbnail_url_rejected.
|
||||
echo ""
|
||||
|
||||
# ── Summary ───────────────────────────────────────────────────────────────────
|
||||
|
||||
echo "════════════════════════════════════════"
|
||||
echo " Results: $PASS passed, $FAIL failed"
|
||||
echo "════════════════════════════════════════"
|
||||
|
||||
[ "$FAIL" -eq 0 ] && exit 0 || exit 1
|
||||
@@ -0,0 +1,86 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
: "${PR_HEAD_SHA:?}"
|
||||
: "${MERGE_SHA:?}"
|
||||
: "${MERGED_AT:?}"
|
||||
: "${VERSION:?}"
|
||||
: "${PR_NUMBER:?}"
|
||||
: "${GH_TOKEN:?}"
|
||||
|
||||
# Keep this list aligned with the main ruleset. Producer IDs prevent a check
|
||||
# with a copied display name from authorizing a release. Every current required
|
||||
# gate is a check run; add explicit legacy-status verification before introducing
|
||||
# any required context that reports only through the commit-status API.
|
||||
required_checks=(
|
||||
"Desktop E2E Integration:15368"
|
||||
"Desktop:15368"
|
||||
"Rust Lint:15368"
|
||||
"Security:15368"
|
||||
"Unit Tests:15368"
|
||||
"Windows Rust (x86_64-pc-windows-msvc):15368"
|
||||
"Mobile:15368"
|
||||
"Web:15368"
|
||||
"Backend Integration (relay e2e):15368"
|
||||
"Desktop E2E Relay:15368"
|
||||
"Relay E2E:15368"
|
||||
"Desktop Build (macOS):15368"
|
||||
"DCO Check:1455659"
|
||||
"Desktop Release Candidate:15368"
|
||||
)
|
||||
|
||||
expected_branch="version-bump/$VERSION"
|
||||
[[ "${PR_HEAD_REF:-}" == "$expected_branch" ]] || { echo "unexpected release branch" >&2; exit 1; }
|
||||
[[ "${PR_BASE_REF:-}" == main ]] || { echo "desktop release must target main" >&2; exit 1; }
|
||||
[[ "${PR_HEAD_REPO:-}" == "$GITHUB_REPOSITORY" ]] || { echo "desktop release must be internal" >&2; exit 1; }
|
||||
|
||||
# The API identity must match the closed event. Branch names are mutable and are
|
||||
# never used to resolve the artifact.
|
||||
pr="$(gh api "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER")"
|
||||
jq -e \
|
||||
--arg head "$PR_HEAD_SHA" --arg head_ref "$PR_HEAD_REF" --arg head_repo "$PR_HEAD_REPO" \
|
||||
--arg base "$PR_BASE_REF" --arg merge "$MERGE_SHA" --arg merged_at "$MERGED_AT" \
|
||||
'.merged == true and .head.sha == $head and .head.ref == $head_ref and
|
||||
.head.repo.full_name == $head_repo and .base.ref == $base and
|
||||
.merge_commit_sha == $merge and .merged_at == $merged_at' <<<"$pr" >/dev/null || {
|
||||
echo "pull request API identity does not match the closed merge event" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Pin trusted verifier code from the candidate's frozen base, not from the
|
||||
# candidate or its squash. A release PR cannot alter the code that validates it.
|
||||
git fetch origin main --no-tags
|
||||
git fetch origin "$PR_HEAD_SHA" --no-tags
|
||||
candidate_parents="$(git show -s --format=%P "$PR_HEAD_SHA")"
|
||||
[[ "$candidate_parents" =~ ^[0-9a-f]{40}$ ]] || {
|
||||
echo "desktop candidate must have exactly one parent before validation" >&2
|
||||
exit 1
|
||||
}
|
||||
git merge-base --is-ancestor "$candidate_parents" origin/main || {
|
||||
echo "desktop candidate base is not protected main history" >&2
|
||||
exit 1
|
||||
}
|
||||
verifier_dir="$(mktemp -d)"
|
||||
trap 'rm -rf "$verifier_dir"' EXIT
|
||||
git show "$candidate_parents:scripts/desktop_release.py" > "$verifier_dir/desktop_release.py"
|
||||
git show "$candidate_parents:scripts/required-check-succeeded.jq" > "$verifier_dir/required-check-succeeded.jq"
|
||||
|
||||
git checkout --detach "$PR_HEAD_SHA"
|
||||
DESKTOP_RELEASE_ROOT="$PWD" python3 "$verifier_dir/desktop_release.py" \
|
||||
validate --candidate "$PR_HEAD_SHA" --version "$VERSION" --repo "$GITHUB_REPOSITORY"
|
||||
|
||||
# `filter=latest` is deliberate: GitHub exposes no per-rerun creation time. A
|
||||
# post-merge rerun replaces the visible attempt and fails closed below.
|
||||
checks="$(gh api --paginate --slurp "repos/$GITHUB_REPOSITORY/commits/$PR_HEAD_SHA/check-runs?filter=latest&per_page=100")"
|
||||
for entry in "${required_checks[@]}"; do
|
||||
required="${entry%:*}"
|
||||
integration_id="${entry##*:}"
|
||||
jq -e --arg name "$required" --argjson integration_id "$integration_id" \
|
||||
--arg merged_at "$MERGED_AT" \
|
||||
-f "$verifier_dir/required-check-succeeded.jq" <<<"$checks" >/dev/null || {
|
||||
echo "trusted required check was not successful at merge: $required" >&2
|
||||
exit 1
|
||||
}
|
||||
done
|
||||
|
||||
echo "verified immutable desktop candidate $PR_HEAD_SHA authorized by merged PR $PR_NUMBER"
|
||||
@@ -0,0 +1,35 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
if [ "$#" -ne 2 ]; then
|
||||
echo "usage: $0 <tag-prefix> <version>" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
tag_prefix=$1
|
||||
version=$2
|
||||
|
||||
if [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]]; then
|
||||
echo "::error::Invalid release version '$version'" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
tag="${tag_prefix}${version}"
|
||||
expected_ref="refs/tags/${tag}"
|
||||
if [ "${GITHUB_REF:-}" != "$expected_ref" ]; then
|
||||
echo "::error::Release must run at ${expected_ref}; got ${GITHUB_REF:-<unset>}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
head_sha=$(git rev-parse 'HEAD^{commit}')
|
||||
if ! tag_sha=$(git rev-parse "refs/tags/${tag}^{commit}" 2>/dev/null); then
|
||||
echo "::error::Release tag '${tag}' is missing from the checkout" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ "$head_sha" != "$tag_sha" ]; then
|
||||
echo "::error::HEAD ${head_sha} does not match ${tag} commit ${tag_sha}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Verified ${expected_ref} at ${head_sha}"
|
||||
Reference in New Issue
Block a user