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

Signed-off-by: cls_宁波本机 <908705107@qq.com>
This commit is contained in:
2026-08-13 18:34:25 +08:00
parent 61c3fa1df9
commit 9dfa06ffee
3785 changed files with 1085458 additions and 2 deletions
+26
View File
@@ -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
+178
View File
@@ -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 $$;
+17
View File
@@ -0,0 +1,17 @@
-- Backfill d_tag for existing NIP-33 range events (kind 3000039999).
-- 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;
+194
View File
@@ -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"
+54
View File
@@ -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"
+34
View File
@@ -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
+174
View File
@@ -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;
}
+109
View File
@@ -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,
);
});
+39
View File
@@ -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
+108
View File
@@ -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);
}
}
+129
View File
@@ -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);
}
}
+113
View File
@@ -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"
+59
View File
@@ -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)
+87
View File
@@ -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.
+34
View File
@@ -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())'
+85
View File
@@ -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)
+287
View File
@@ -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()
+62
View File
@@ -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"
+204
View File
@@ -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
+952
View File
@@ -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"
+48
View File
@@ -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"
+213
View File
@@ -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)")
+166
View File
@@ -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
+97
View File
@@ -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;
+169
View File
@@ -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
+66
View File
@@ -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
+93
View File
@@ -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})"
+17
View File
@@ -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"
}
}
+98
View File
@@ -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"/![$NAME]($URL)}"
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'"![${NAME}](${IMAGE_URL_MAP[$NAME]})"
done
fi
else
COMMENT_BODY="## Screenshots"$'\n\n'
for URL in "${IMAGE_URLS[@]}"; do
FILENAME=$(basename "$URL")
NAME="${FILENAME%.png}"
COMMENT_BODY+="![${NAME}](${URL})"$'\n\n'
done
fi
gh pr comment "$PR" --repo "$REPO" --body "$COMMENT_BODY"
echo "Posted ${#PNGS[@]} screenshot(s) to PR #${PR}"
+83
View File
@@ -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"
+54
View File
@@ -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
}
+25
View File
@@ -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"
)
+69
View File
@@ -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"
+48
View File
@@ -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"
+185
View File
@@ -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
+194
View File
@@ -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![image](http://localhost:3000/media/__SEARCH_IMAGE_HASH__.png)', '[["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![image](http://localhost:3000/media/__WORKSPACE_IMAGE_HASH__.png)\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![image](http://localhost:3000/media/__QUALITY_IMAGE_HASH__.png)', '[["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."
+98
View File
@@ -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/"
+132
View File
@@ -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."
+16
View File
@@ -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 "$@"
+170
View File
@@ -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
+200
View File
@@ -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
+43
View File
@@ -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"
+194
View File
@@ -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"
+78
View File
@@ -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'
+97
View File
@@ -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"
+301
View File
@@ -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"
+249
View File
@@ -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'
+176
View File
@@ -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"
+40
View File
@@ -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"
+43
View File
@@ -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)"
+378
View File
@@ -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
+86
View File
@@ -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"
+35
View File
@@ -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}"