The daemon-mode pass repeats the whole CLI suite single-threaded at
about eight minutes per pass, and the job has been cancelled at the
15-minute mark on every master push since it landed - which is what
kept the v13.0.0 release gate shut.
Only 25 of the 83 CLI e2e tests actually reached a daemon: the helpers that
build a command bypassed the one place that routed. Routing at
TestServer::args instead brings that to 39 and, more importantly, puts the
streaming tests through a daemon — event.room and event.message delivery to a
remote client's mirror had no end-to-end coverage at all.
Adds what the command surface structurally cannot reach:
- Two clients against one daemon at once, which is the reason the hub exists
and was proven nowhere in CI.
- tests/daemon_protocol_e2e.rs, speaking the socket by hand: TCP token
required and socket token not, pre-auth refusal, error ids, notification
silence, the line cap, the handshake timeout, and that a call shaped by the
published contract is accepted.
- daemon status/stop/token as commands.
- A TUI unit test for conversation seeding, the part of the attach path that
had actually been broken.
The protocol suite caught a regression in yesterday's hardening on its first
run: the socket was shut down before the writer flushed a failed handshake's
refusal, so a client with a wrong token saw the connection vanish instead of
being told why.
SOULSEEK_E2E_MODE=daemon makes the existing e2e harness serve every session
from a daemon instead of the command's own login, so the same 83 tests assert
the same records and exit codes both ways. CI runs the suite twice.
Two defects it found:
serve judged whether anything was shared from this run's configuration
rather than from the session, so routed to a daemon it would start with
nothing to serve and report success.
The TUI showed an empty conversation when attached, because the daemon's
message history was never asked for. It is now a SessionApi method, empty
locally, which puts the difference behind the seam.
main.rs is now a thin entry point over the library rather than a second copy
of every module, so the program compiles once instead of twice.
release-plz opens the release PR as app/github-actions, and GitHub parks
workflow runs from that actor at action_required, so the PR shows no checks
until someone approves each one by hand. Passing a PAT instead makes the PR
come from that identity and its CI runs on its own.
The fallback keeps today's behaviour until RELEASE_PLZ_TOKEN exists, so this
is inert until you set it. Only the PR job needs it; tagging and publishing
are fine with GITHUB_TOKEN.
The homebrew job needed a cross-repo PAT in HOMEBREW_TAP_TOKEN, which resolved
to an empty string and failed every release with "Input required and not
supplied: token". A tap can write to itself with the GITHUB_TOKEN it already
has, so the formula is now generated by an Update formula workflow in
michel/homebrew-tap that reads the latest release and commits when it changes.
That removes the secret from the release path entirely. Verified by corrupting
the formula and watching the workflow put it back.
The empty homebrew tap was never a condition problem. The release job ran, the
matrix built all six targets, and the tap step then failed with "Input required
and not supplied: token" because HOMEBREW_TAP_TOKEN resolves to an empty
string. Four rounds of edits to those `if` expressions chased a symptom, so
they go back to what they were.
What stays is the CI concurrency change: a cancelled master run leaves the
release gate waiting on a CI run for a commit nothing will push again.
Three context spellings were tried in these job conditions and all three left
the jobs skipped: `inputs.tag`, `github.event.inputs.tag`, and
`needs.release.outputs.tag`. The run logs show `Resolve tag` succeeding with
INPUT_TAG=v7.0.0 and writing the output, so the value exists in the step and
is gone by the time a downstream `if` reads it.
`github.event_name` is the one context this file already proves works in a job
condition, because ci-gate skips correctly on dispatch. Gate on that, and echo
both outputs at the end of the release job so the next failure names itself
instead of costing four runs.
Neither `inputs.tag` nor `github.event.inputs.tag` evaluates in a job-level
`if` on workflow_dispatch, so both spellings left the binaries and homebrew
jobs skipped on every manual recovery run, while the step-level condition on
the same input fired correctly and resolved the tag. The run log settles it:
INPUT_TAG reached the step as v7.0.0 and the jobs still skipped.
`Resolve tag` already runs exactly when there is something to build, so its
output is the honest signal, and a `needs` output does evaluate here.
The `tag` dispatch input exists so binaries and the homebrew formula can be
rebuilt for a tag that already shipped. It never worked: the job-level
conditions read `inputs.tag`, which is empty in a job `if` on
workflow_dispatch, and `null != ''` coerces to false, so both jobs silently
skipped. That is why v7.0.0 reached crates.io with an empty homebrew tap.
The step-level condition used the same expression and did fire, which is what
made the two disagree. `github.event.inputs.tag` is populated in both places.
The cursor sat 0.06em after the text, close enough to read as a stray glyph.
The wordmark SVG puts it 15.4 units after a 27-unit block, so 0.57 cursor
widths, which is 0.33em at the component's 0.58em cursor.
Add a release job that regenerates Formula/soulseek-rs.rb in michel/homebrew-tap
from the archives the binaries matrix uploads, and lead with
`brew install michel/tap/soulseek-rs` in the READMEs and on the website.
The one-shot half of the binary could not finish its headline task without a
human at the keyboard: `search` opened a full-screen picker, panicked with
exit 101 when there was no terminal, and no command could fetch a known file
at all. Everything exited 0 whatever happened, wrote emoji prose to stdout,
and ignored --download-dir, SOULSEEK_DOWNLOAD_DIR and config.toml.
The commands are now built for scripts and agents:
search download get browse room list|say|listen message send|read
stdout carries records only (tab-separated, or NDJSON under --json), stderr
carries progress, and the exit code carries the verdict: 2 usage, 3 connect,
4 no results, 5 timed out, 6 transfer failed. `get` does search, rank and
fetch in one step, and `search --json | jq ... | download --stdin` composes.
Global flags parse in any position and every one has a SOULSEEK_ variable and
a config.toml key resolved through a single precedence chain.
Defects found and fixed along the way, each covered by a test:
- a download with an unknown size was finalized against zero bytes and saved
as an empty file; the size now comes from the peer's share listing
- transfers are keyed by a hash of the remote path, so the same path from two
peers shadowed itself and two files sharing a basename overwrote each
other; both collisions are now dropped with a warning
- `message send` and `room say` slept 500ms and claimed success; they now
wait for a server reply queued behind the message before reporting
- Client::login waited on an unbounded channel and never returned when the
server went quiet
Deleted the TUI picker the old `search` drove (download_selector,
MultiDownloadProgress, SearchConfig): 1,573 lines out, 926 in. The
interactive TUI is otherwise untouched.
BREAKING CHANGE: `rooms` and `chat` become `room list|say|listen` and
`message send|read`; `--disable-listener` becomes `--no-listener`; the
unprefixed DISABLE_LISTENER, LISTENER_PORT, LOG_FILE and
MAX_CONCURRENT_DOWNLOADS variables are now SOULSEEK_-prefixed; `search` no
longer opens a picker or downloads anything.
Removes every comment introduced on this branch: website/ in full, pages.yml
in full, and only the blocks added to ci.yml and release.yml — their
pre-existing comments are untouched.
Two directives survive because they are load-bearing rather than prose: the
triple-slash vite/client reference, and nothing else.
Two comments were carrying behaviour that the code did not state, so the
code now states it. useCopy's rejection handler clears the copied flag
instead of sitting empty, and writeTheme returns whether the write actually
landed instead of swallowing the failure in a bare catch. isExternal drops
its regex for two startsWith calls, which reads the same and no longer
hides a `//` inside a literal.
A Vite + React + TypeScript + Tailwind v4 site under website/, built from
the soulseek-rs design system so the site and the TUI stay one product.
The colour tokens in src/index.css are the same values as
soulseek-rs/src/ui/styles.rs; change one, change both.
The hero is a working recreation of the TUI: it plays one scripted run and
then takes the keyboard, or lands on the resting state directly under
prefers-reduced-motion. Its sample data is invented or public domain, per
the tone-of-voice guardrail against naming copyrighted material.
Static output rather than an SPA shell. `bun run build` compiles the client,
compiles the same app for Node, then renders every route in src/lib/routes.ts
to its own file with that route's title, description and canonical URL. A
direct link to /install therefore answers 200 with real markup instead of
GitHub's 404 page. routes.ts is keyed into App.tsx by literal path, so adding
one without the other fails typecheck rather than shipping a page whose body
says "not found" under the right title.
Theme is resolved once by an inline script before first paint and adopted by
React through useSyncExternalStore, so there is no flash and no hydration
mismatch on the toggle. Light mode re-cuts the semantic hues darker: phosphor
and tape read on vinyl but wash out on manila.
CI and release skip website-only pushes, and Pages skips Rust-only ones. The
two Rust workflows share one paths-ignore list so they can only skip
together, which keeps the release ci-gate from polling for a run that was
never queued. That gate also now treats a cancelled CI as "keep waiting"
rather than a failure, since cancel-in-progress cancels the older SHA on
every back-to-back push.
The e2e suite talks to a real soulfind server over the network. On 298ec5a
it stopped responding and sat in_progress for over half an hour with every
other job green. No job declared timeout-minutes, so GitHub's six hour
default applied: six hours of runner time, and six hours during which the
new release gate correctly refuses to publish.
Limits are sized well above observed runtimes (lint ~1m, fmt ~15s, test
~2m, e2e ~1m) so they only fire on a genuine hang. A hang now surfaces as a
bounded red run that can be re-run, rather than an unbounded one.
This does not address why e2e hangs; it stops that from being unbounded.
Two gaps left over from the v6.0.0 release.
The v6.0.0 archives shipped unstripped: 3.63 MB vs 4.53 MB locally, 168
symbols instead of 9483. Setting it on the profile rather than in the
workflow covers every target at once and `cargo install` along with them.
Nothing gated the crates.io publish. A published version can be yanked but
never withdrawn, so the release job now waits for the CI run on the same
commit and refuses to publish unless it is green. release-pr deliberately
does not wait; opening the release PR is harmless.
This is not hypothetical: CI failed on eef7bf7, the v6.0.0 release commit.
It was a flaky e2e timeout rather than a regression - the very next commit
touched no code and passed - but the gate would have stopped that release,
which is the point. The gate reads the newest CI run for the commit, so
re-running a flaky job and then re-running Release clears it.
upload-rust-binary-action reaches for `cross` on any *-musl target. Every
entry in the matrix builds on a runner of its own architecture — only the
libc differs — so cross buys nothing, and on the native arm64 runner it
hard-failed:
error: toolchain 'stable-x86_64-unknown-linux-gnu' may not be able to
run on this system
That cost v6.0.0 its aarch64-unknown-linux-musl archive; the other five
targets uploaded because the matrix is fail-fast: false.
Re-running the failed job from the Actions UI replays the old workflow file,
which is useless when the workflow is the bug. So the Release workflow now
takes an optional `tag` input that rebuilds and re-uploads binaries for an
existing tag. That value reaches actions/checkout as `ref:` and is typed by
whoever dispatches, so it is checked against a literal version tag first.
The push and pull_request triggers both matched every branch, so each PR
kicked off two identical runs of lint, the three-OS test matrix and the
soulfind e2e job. Push now only covers master; branches are covered by the
pull_request trigger. Adds a concurrency group so a follow-up push cancels
the superseded run instead of queueing behind it.
Replace the 326-line semantic-release/npm workflow with release-plz, which
handles the whole chain the old one only half did:
- versioning and CHANGELOG from the conventional commits already in use
- a `chore: release vX.Y.Z` PR as the gate; merging it tags and releases
- publishes both crates to crates.io in dependency order, waiting for the
index between them (RELEASE.md used to ask you to do this by hand)
- release-plz.toml keeps the two crates on one version, one tag, one changelog
Binaries now cover six targets instead of four. Linux switches from gnu to
musl so the downloads are statically linked and stop caring about the host's
glibc version; linux-aarch64 and windows-aarch64 are new. Archives carry
sha256 checksums plus README and LICENSE.
CI drops twelve hand-rolled actions/cache steps for Swatinem/rust-cache.
Extend the existing CI workflow with an ubuntu-only "End-to-end (soulfind)"
job that builds soulfind from source (LDC + `dub build :server`), points
SOULFIND_BIN at it, and runs the e2e suite with SOULSEEK_E2E_REQUIRED=1.
Also widen the clippy job to the whole workspace.
The e2e harness gains SOULSEEK_E2E_REQUIRED: when set, a missing/unstartable
server is a hard failure instead of a silent skip, so CI genuinely exercises
the client end-to-end. Add e2e coverage for two existing behaviours a single
server can verify: wrong-password rejection and concurrent logins.
- Add check-release job to determine if a release is needed
- Build binaries in parallel for all platforms before release
- Create release and attach binaries in final step
- Start with version 0.1.0