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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 59 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -775,6 +775,63 @@ jobs:
${{ steps.artifacts.outputs.exe }}
${{ steps.artifacts.outputs.sig }}

desktop-release-smoke:
name: Desktop release smoke
if: github.repository == 'block/buzz'
runs-on: ubuntu-latest
needs: setup
timeout-minutes: 20
permissions:
contents: read
env:
PLAYWRIGHT_BROWSERS_PATH: ${{ github.workspace }}/.cache/ms-playwright
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
ref: ${{ needs.setup.outputs.source_sha }}
persist-credentials: false
- uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1
- name: Install desktop dependencies
run: just desktop-install-ci
- name: Get Playwright version
id: pw-version
run: echo "version=$(cd desktop && node -e \"console.log(require('@playwright/test/package.json').version)\")" >> "$GITHUB_OUTPUT"
- name: Restore Playwright browser cache
id: playwright-cache
uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5
with:
path: ${{ env.PLAYWRIGHT_BROWSERS_PATH }}
key: playwright-${{ runner.os }}-${{ steps.pw-version.outputs.version }}
- name: Install Playwright Chromium
if: steps.playwright-cache.outputs.cache-hit != 'true'
run: cd desktop && pnpm exec playwright install chromium
- name: Install Playwright system dependencies
run: cd desktop && pnpm exec playwright install-deps chromium
- name: Save Playwright browser cache
if: steps.playwright-cache.outputs.cache-hit != 'true'
uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5
with:
path: ${{ env.PLAYWRIGHT_BROWSERS_PATH }}
key: playwright-${{ runner.os }}-${{ steps.pw-version.outputs.version }}
- name: Build test relay
run: cargo build --profile ci -p buzz-relay
- name: Run deterministic correctness smoke
env:
BUZZ_E2E_RELAY_BIN: ${{ github.workspace }}/target/ci/buzz-relay
BUZZ_RELEASE_SMOKE_ARTIFACT_DIR: ${{ github.workspace }}/release-smoke-artifacts
run: just desktop-release-smoke
- name: Upload release-smoke diagnostics
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: desktop-release-smoke
path: |
release-smoke-artifacts
desktop/test-results
desktop/playwright-release-smoke-report
if-no-files-found: warn
retention-days: 14

assemble-manifest:
name: Assemble multi-platform latest.json
# Only the tag-bound setup path can reach this job.
Expand All @@ -785,9 +842,10 @@ jobs:
needs.release-macos-x64.result == 'success' &&
needs.release-linux.result == 'success' &&
needs.release-windows.result == 'success' &&
needs.desktop-release-smoke.result == 'success' &&
github.ref == format('refs/tags/desktop-v{0}', needs.setup.outputs.version)
runs-on: ubuntu-latest
needs: [setup, release, release-macos-x64, release-linux, release-windows]
needs: [setup, release, release-macos-x64, release-linux, release-windows, desktop-release-smoke]
timeout-minutes: 10
permissions:
contents: write
Expand Down
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions Justfile
Original file line number Diff line number Diff line change
Expand Up @@ -276,6 +276,10 @@ desktop-e2e-smoke:
desktop-e2e-integration: _ensure-migrations
cd {{desktop_dir}} && pnpm test:e2e:integration

# Run the deterministic desktop correctness smoke against an isolated local relay
desktop-release-smoke:
./scripts/run-desktop-release-smoke.sh

# Run only the e2e specs changed vs origin/main (both projects) before pushing
desktop-e2e-pre-push: _ensure-migrations
git fetch origin main
Expand Down
44 changes: 42 additions & 2 deletions TESTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -155,9 +155,49 @@ buzz messages thread --channel "$CHANNEL" --event "$EVENT_ID" | jq .

A successful run prints `{"event_id":"…","accepted":true,"message":""}` for
the send, and the message body in the `get` output. `thread` returns `[]`
for a leaf message — populated only after a reply comes in (see §5).
for a leaf message — populated only after a reply comes in (see §6).

### 5. Going deeper
### 5. Verify a roster beyond 1,000 members

Use the focused live-relay script when changing channel membership, discovery,
or reconciliation. It proves the three boundaries that DB-only tests cannot:
the relay-served kind 39002 includes a member at roster position 1,501, that
identity can publish a channel message, and targeted reconciliation preserves
its discoverability.

Run this only against an isolated local database. The script inserts fixture
members directly, then drives discovery and messaging through the release CLI
and relay. Keep the release relay from step 3 running and use its configured
relay key for authoritative replacement:

```bash
export PATH="$PWD/target/release:$PATH"
export DATABASE_URL="postgres://buzz:buzz_dev@localhost:5432/buzz_roster_e2e"
export BUZZ_RELAY_URL="http://localhost:3030" # match the relay from step 3
export RELAY_URL="ws://localhost:3030"
export BUZZ_RELAY_PRIVATE_KEY="<same key used by buzz-relay>"

scripts/e2e-large-channel-roster.sh
```

Success is directly observable as four `PASS` lines. The first and fourth
include a member count greater than 1,000 and the same late-member pubkey; the
second includes the accepted kind 9 event ID, and the third proves targeted
repair left kind 39000/39001 IDs and tags unchanged:

```text
PASS discovery-before-republish channel=<uuid> members=1502 late_pubkey=<hex>
PASS late-member-action event_id=<hex>
PASS targeted-repair-preserves-metadata-and-admin-events channel=<uuid>
PASS discovery-after-republish channel=<uuid> members=1502 late_pubkey=<hex>
```

The script refuses debug binaries and refuses a `buzz` or `buzz-admin` resolved
outside this checkout's `target/release`. It also requires the targeted admin
operation to use `BUZZ_RELAY_PRIVATE_KEY`; never substitute an ephemeral signer
for an authoritative replacement.

### 6. Going deeper

For full coverage of every CLI command (54 subcommands across 12 groups),
follow [`crates/buzz-cli/TESTING.md`](crates/buzz-cli/TESTING.md).
Expand Down
1 change: 1 addition & 0 deletions crates/buzz-admin/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -35,4 +35,5 @@ rustls = { version = "0.23", default-features = false, features = ["ring", "std"
tracing = { workspace = true }
sqlx = { workspace = true }
url = { workspace = true }
uuid = { workspace = true }
clap = { version = "4", features = ["derive"] }
138 changes: 88 additions & 50 deletions crates/buzz-admin/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -88,12 +88,17 @@ enum Command {
#[command(subcommand)]
command: deletions::DeletionsCommand,
},
/// Emit kind:39000/39002 events for channels missing them.
/// Emit missing kind:39000/39001/39002 channel discovery events, or
/// republish only a targeted channel's kind:39002 roster.
///
/// Channels created via direct SQL (seed scripts, pre-migration data) won't
/// have Nostr discovery events. This command creates them so pure-nostr
/// clients can see those channels. Idempotent — safe to run multiple times.
/// Without `--channel`, only channels missing discovery metadata are
/// reconciled. With `--channel`, only that channel's member snapshot is
/// replaced; canonical metadata and admin events remain untouched.
ReconcileChannels {
/// Optional channel UUID to force-republish.
#[arg(long)]
channel: Option<String>,

/// Relay private key (hex) for signing events. Falls back to
/// BUZZ_RELAY_PRIVATE_KEY env var. If neither is set, generates
/// an ephemeral key (events will be unverifiable after restart).
Expand Down Expand Up @@ -156,8 +161,8 @@ async fn run(cli: Cli) -> Result<i32> {
command: ProductFeedbackCommand::List { limit },
} => cmd_list_product_feedback(limit).await,
Command::Deletions { command } => deletions::run(command).await,
Command::ReconcileChannels { relay_key } => {
reconcile_channels(relay_key).await?;
Command::ReconcileChannels { channel, relay_key } => {
reconcile_channels(channel, relay_key).await?;
Ok(0)
}
}
Expand Down Expand Up @@ -466,14 +471,26 @@ async fn resolve_admin_tenant(db: &Db) -> Result<TenantContext> {
Ok(TenantContext::resolved(record.id, record.host))
}

async fn reconcile_channels(relay_key_arg: Option<String>) -> Result<()> {
async fn reconcile_channels(
channel_arg: Option<String>,
relay_key_arg: Option<String>,
) -> Result<()> {
use buzz_core::kind::KIND_NIP29_GROUP_ADMINS;
use buzz_db::event::EventQuery;

let db = connect_db().await?;

// Resolve relay signing key: arg > env > ephemeral
let relay_keys = match relay_key_arg.or_else(|| std::env::var("BUZZ_RELAY_PRIVATE_KEY").ok()) {
// Resolve relay signing key: arg > env > ephemeral. Force-republish must
// never use an ephemeral key because it replaces an existing authoritative
// snapshot.
let configured_relay_key =
relay_key_arg.or_else(|| std::env::var("BUZZ_RELAY_PRIVATE_KEY").ok());
if channel_arg.is_some() && configured_relay_key.is_none() {
return Err(anyhow::anyhow!(
"--channel requires --relay-key or BUZZ_RELAY_PRIVATE_KEY"
));
}
let relay_keys = match configured_relay_key {
Some(key_hex) => {
Keys::parse(&key_hex).map_err(|e| anyhow::anyhow!("invalid relay key: {e}"))?
}
Expand All @@ -490,7 +507,21 @@ async fn reconcile_channels(relay_key_arg: Option<String>) -> Result<()> {
};

let tenant = resolve_admin_tenant(&db).await?;
let channels = db.list_channels(tenant.community(), None).await?;
let target_channel = channel_arg
.as_deref()
.map(uuid::Uuid::parse_str)
.transpose()
.map_err(|e| anyhow::anyhow!("invalid --channel UUID: {e}"))?;
let channels = if let Some(target) = target_channel {
vec![db
.get_channel(tenant.community(), target)
.await
.map_err(|_| {
anyhow::anyhow!("channel {target} not found in community {}", tenant.host())
})?]
} else {
db.list_channels(tenant.community(), None).await?
};
if channels.is_empty() {
println!("No channels in database.");
return Ok(());
Expand All @@ -513,57 +544,64 @@ async fn reconcile_channels(relay_key_arg: Option<String>) -> Result<()> {
.await
.unwrap_or_default();

if !existing.is_empty() {
if !existing.is_empty() && target_channel.is_none() {
skipped += 1;
continue;
}

let members = db.get_members(tenant.community(), channel.id).await?;

// kind:39000 — channel metadata
{
let mut tags: Vec<Tag> = vec![Tag::parse(["d", &channel_id_str])?];
tags.push(Tag::parse(["name", &channel.name])?);
if let Some(ref desc) = channel.description {
if !desc.is_empty() {
tags.push(Tag::parse(["about", desc])?);
// A targeted repair is deliberately roster-only. kind:39000 metadata
// is richer than this legacy backfill builder, and kind:39001 is not
// part of the stale-roster incident; replacing either can destroy
// canonical state. Full backfill still creates all three event kinds
// for channels with no discovery metadata.
if target_channel.is_none() {
// kind:39000 — channel metadata
{
let mut tags: Vec<Tag> = vec![Tag::parse(["d", &channel_id_str])?];
tags.push(Tag::parse(["name", &channel.name])?);
if let Some(ref desc) = channel.description {
if !desc.is_empty() {
tags.push(Tag::parse(["about", desc])?);
}
}
if channel.visibility == "private" {
tags.push(Tag::parse(["private"])?);
} else {
tags.push(Tag::parse(["public"])?);
}
if channel.channel_type == "dm" {
tags.push(Tag::parse(["hidden"])?);
}
tags.push(Tag::parse(["closed"])?);
tags.push(Tag::parse(["t", &channel.channel_type])?);

let event = EventBuilder::new(Kind::Custom(39000), "")
.tags(tags)
.sign_with_keys(&relay_keys)
.map_err(|e| anyhow::anyhow!("sign kind:39000: {e}"))?;
db.replace_addressable_event(tenant.community(), &event, Some(channel.id))
.await?;
}
if channel.visibility == "private" {
tags.push(Tag::parse(["private"])?);
} else {
tags.push(Tag::parse(["public"])?);
}
if channel.channel_type == "dm" {
tags.push(Tag::parse(["hidden"])?);
}
tags.push(Tag::parse(["closed"])?);
tags.push(Tag::parse(["t", &channel.channel_type])?);

let event = EventBuilder::new(Kind::Custom(39000), "")
.tags(tags)
.sign_with_keys(&relay_keys)
.map_err(|e| anyhow::anyhow!("sign kind:39000: {e}"))?;
db.replace_addressable_event(tenant.community(), &event, Some(channel.id))
.await?;
}

// kind:39001 — admins
{
let mut tags: Vec<Tag> = vec![Tag::parse(["d", &channel_id_str])?];
for m in members
.iter()
.filter(|m| m.role == "owner" || m.role == "admin")
// kind:39001 — admins
{
let pk = hex::encode(&m.pubkey);
tags.push(Tag::parse(["p", &pk, &m.role])?);
let mut tags: Vec<Tag> = vec![Tag::parse(["d", &channel_id_str])?];
for m in members
.iter()
.filter(|m| m.role == "owner" || m.role == "admin")
{
let pk = hex::encode(&m.pubkey);
tags.push(Tag::parse(["p", &pk, &m.role])?);
}
let event = EventBuilder::new(Kind::Custom(KIND_NIP29_GROUP_ADMINS as u16), "")
.tags(tags)
.sign_with_keys(&relay_keys)
.map_err(|e| anyhow::anyhow!("sign kind:39001: {e}"))?;
db.replace_addressable_event(tenant.community(), &event, Some(channel.id))
.await?;
}
let event = EventBuilder::new(Kind::Custom(KIND_NIP29_GROUP_ADMINS as u16), "")
.tags(tags)
.sign_with_keys(&relay_keys)
.map_err(|e| anyhow::anyhow!("sign kind:39001: {e}"))?;
db.replace_addressable_event(tenant.community(), &event, Some(channel.id))
.await?;
}

// kind:39002 — members
Expand Down
Loading
Loading