From b31039b9ae064782e5ea74da1d47f115403d09f3 Mon Sep 17 00:00:00 2001 From: Souta Date: Wed, 16 Sep 2026 14:05:47 +0900 Subject: [PATCH 1/3] Ship a binary per platform, and tell people to curl it The extension is on the store and public, so people arrive without a Rust toolchain or a C compiler. What the release carried was the extension zip, so the only way to the daemon was `cargo install`, which needs both. Four targets now: windows-msvc, both darwins, linux-gnu. The Linux one builds on the older runner on purpose -- a binary linked against a newer glibc will not start on an older one, and the people this is for are on login nodes somebody else administers. musl would remove that floor and is not here yet, because `ring` wants a C toolchain targeting musl and an untested build inside a release job fails at the worst moment. Unsigned, and that is a decision rather than a corner cut. Gatekeeper and SmartScreen act on a flag the *downloading application* attaches -- `com.apple.quarantine`, Mark-of-the-Web -- and `curl` and `scp` attach neither. A command-line tool installed the way command-line tools are installed meets no warning at all. Signing is a few hundred a year to remove one from the single route nothing here recommends, so the README leads with `curl` and says why, and the extension's no-daemon screen prints a `curl` line for the platform reading it rather than linking to a page somebody would download from. `cargo binstall ssh-browser` works off the same assets, spelled out in `[package.metadata.binstall]` rather than left to the defaults: it tries several name shapes and a near-miss falls back to building from source, which is the compiler this exists to avoid, arrived at silently. The e2e check on that screen asserted `cargo install`. It now asserts a download and, separately, that no bare URL appears -- a link is the one thing that would put the quarantine flag back. Signed-off-by: Souta --- .github/workflows/release-assets.yml | 76 ++++++++++++++++++++++++++++ README.md | 35 +++++++++++-- crates/ssh-browser/Cargo.toml | 12 +++++ e2e/run.mjs | 13 ++++- extension/src/dashboard.ts | 42 ++++++++++++++- 5 files changed, 169 insertions(+), 9 deletions(-) diff --git a/.github/workflows/release-assets.yml b/.github/workflows/release-assets.yml index 916cba5..c93e01e 100644 --- a/.github/workflows/release-assets.yml +++ b/.github/workflows/release-assets.yml @@ -29,6 +29,82 @@ permissions: contents: read jobs: + daemon: + # A binary per platform, so installing the daemon is a download rather than a Rust + # toolchain and a C compiler. The extension is on the store now, which means people arrive + # without either. + # + # Unsigned, deliberately. Gatekeeper and SmartScreen act on a flag the *downloading + # application* attaches — `com.apple.quarantine`, Mark-of-the-Web — and `curl` and `scp` do + # not attach one. A command-line tool installed the way command-line tools are installed + # meets neither. Signing would cost a few hundred a year to remove a warning on the one + # route the README does not recommend. + name: ${{ matrix.target }} + runs-on: ${{ matrix.os }} + permissions: + contents: write + strategy: + # Every platform that can build should, even when one cannot. A macOS runner out of + # capacity is not a reason to leave Linux users with nothing. + fail-fast: false + matrix: + include: + - target: x86_64-pc-windows-msvc + os: windows-latest + exe: .exe + - target: aarch64-apple-darwin + os: macos-latest + exe: "" + - target: x86_64-apple-darwin + os: macos-latest + exe: "" + # Built on the older runner on purpose: a binary linked against a newer glibc will + # not start on an older one, and the people this is for are on login nodes somebody + # else administers. `musl` would remove the floor entirely and is not here yet, + # because `ring` wants a C toolchain targeting musl and an untested build inside a + # release job fails at the worst possible moment. + - target: x86_64-unknown-linux-gnu + os: ubuntu-22.04 + exe: "" + env: + TAG: ${{ inputs.tag }} + TARGET: ${{ matrix.target }} + EXE: ${{ matrix.exe }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ inputs.tag }} + persist-credentials: false + - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 + with: + toolchain: stable + targets: ${{ matrix.target }} + + # `--locked` so the binary somebody downloads was built from the dependency versions in + # the tag rather than from whatever resolved on the day. + - name: build + run: cargo build --release --locked --bin ssh-browser --target ${{ matrix.target }} + + # Named for the target rather than the runner: that is the string a reader matches + # against their own machine, and it is what `cargo-binstall` looks for. + - name: name it after the platform + shell: bash + run: | + version=$(grep -m1 '^version = ' Cargo.toml | cut -d'"' -f2) + out="ssh-browser-$version-$TARGET$EXE" + cp "target/$TARGET/release/ssh-browser$EXE" "$out" + # Published so somebody can check what they downloaded against what this built, + # which is what an unsigned binary can offer instead of a signature. + shasum -a 256 "$out" > "$out.sha256" + cat "$out.sha256" + echo "ASSET=$out" >> "$GITHUB_ENV" + + - name: upload + shell: bash + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: gh release upload "$TAG" "$ASSET" "$ASSET.sha256" --clobber + extension: name: attach the extension runs-on: ubuntu-latest diff --git a/README.md b/README.md index efb3696..93b4247 100644 --- a/README.md +++ b/README.md @@ -157,15 +157,40 @@ that is most of them, and all of what `file://` breaks. ## Usage +Every release carries a binary per platform. One file, nothing to unpack: + +``` +curl -L https://github.com/QAtlasHub/ssh-browser/releases/latest/download/ssh-browser-x86_64-unknown-linux-gnu -o ssh-browser +chmod +x ssh-browser +./ssh-browser serve docs=myhost:/srv/docs cluster=login-node +``` + +Swap the target for `aarch64-apple-darwin`, `x86_64-apple-darwin` or +`x86_64-pc-windows-msvc.exe`. Each has a `.sha256` beside it. + +**`curl`, not your browser — and that is why it is the first thing on this page.** These +binaries are unsigned, and macOS and Windows both refuse an unsigned executable: but only one +the *browser* downloaded. Gatekeeper reads a `com.apple.quarantine` flag and SmartScreen a +Mark-of-the-Web, and both are attached by the downloading application. `curl` and `scp` attach +neither. A signature would cost a few hundred a year to remove a warning on the one route this +page does not recommend. + +With cargo, the same binary without the URL: + +``` +cargo binstall ssh-browser +``` + +Or build it, which is also what to do for a platform not in that list: + ``` cargo install ssh-browser -ssh-browser serve docs=myhost:/srv/docs cluster=login-node ``` -**What that needs on your machine:** a Rust toolchain and a C compiler. The C compiler is for -`ring`, which the TLS stack compiles — `cc` on Linux and macOS, the MSVC build tools or a MinGW -toolchain on Windows. Nothing else: no OpenSSL, no system TLS library, no daemon, no service. -Everything TLS is inside the binary, and `deny.toml` bans the OpenSSL crates so it stays that way. +**That last one needs a Rust toolchain and a C compiler.** The C compiler is for `ring`, which +the TLS stack compiles — `cc` on Linux and macOS, the MSVC build tools or a MinGW toolchain on +Windows. Nothing else: no OpenSSL, no system TLS library, no daemon, no service. Everything TLS +is inside the binary, and `deny.toml` bans the OpenSSL crates so it stays that way. At runtime it needs `ssh` on your `PATH` and nothing more. Trusting the https certificate uses a command your OS already has — `certutil`, `security`, or `update-ca-certificates` — and diff --git a/crates/ssh-browser/Cargo.toml b/crates/ssh-browser/Cargo.toml index f9676ad..3bcf784 100644 --- a/crates/ssh-browser/Cargo.toml +++ b/crates/ssh-browser/Cargo.toml @@ -12,6 +12,18 @@ keywords = ["ssh", "sftp", "browser", "proxy", "origin"] # from a shell would be neither. categories = ["network-programming", "command-line-utilities"] +# Where the built binaries are, so `cargo binstall ssh-browser` takes one instead of +# compiling. Nothing but `cargo-binstall` reads this table; cargo ignores `package.metadata`. +# +# Spelled out rather than left to its defaults, because it tries several shapes and a +# near-miss falls back to building from source — which is the C compiler this exists to avoid, +# arrived at silently. The names come from `release-assets.yml`; if one moves, both move. +[package.metadata.binstall] +pkg-url = "{ repo }/releases/download/v{ version }/ssh-browser-{ version }-{ target }{ binary-ext }" +# Not an archive. One file is the whole program, and unpacking a tarball that contains a +# single binary is a step which exists only because most projects have more than one. +pkg-fmt = "bin" + [lints] workspace = true diff --git a/e2e/run.mjs b/e2e/run.mjs index 8325c04..b6b19e5 100644 --- a/e2e/run.mjs +++ b/e2e/run.mjs @@ -978,8 +978,17 @@ async function main() { })); check("with no daemon, the first run says what this is and what to run", () => { assert.match(firstRun.view, /daemon is not running/); - assert.match(firstRun.cmd, /cargo install ssh-browser/); - assert.match(firstRun.cmd, /ssh-browser serve/); + // A download rather than a build. Somebody arriving from the store has neither a Rust + // toolchain nor a C compiler, and `cargo install` was a wall dressed as an instruction. + assert.match(firstRun.cmd, /curl -L -o ssh-browser/); + assert.match(firstRun.cmd, /releases\/latest\/download\/ssh-browser-/); + // `.exe` on Windows, none elsewhere, so the check is about the instruction rather + // than the filename. This runs on whichever platform is running it -- souta's + // Windows and CI's Linux take different branches of the same function. + assert.match(firstRun.cmd, /ssh-browser(\.exe)? serve/); + // Not a link to the releases page: a browser download is the one thing that attaches + // the quarantine flag these unsigned binaries would then be refused for. + assert.doesNotMatch(firstRun.cmd, /^https/m); assert.ok(firstRun.repo.includes("QAtlasHub/ssh-browser"), `no source link: ${firstRun.repo}`); }); // Two versions of the same bad news, one of them in red, reads as two problems. diff --git a/extension/src/dashboard.ts b/extension/src/dashboard.ts index 560526b..791c47a 100644 --- a/extension/src/dashboard.ts +++ b/extension/src/dashboard.ts @@ -301,6 +301,38 @@ function renderList(): void { /// extension for the store, who will install it with no daemon anywhere. One red line /// naming a command they have never heard of is not enough to act on, and "does not /// function" is a fair reading of it. +/// What to run to get the daemon, for the platform reading this. +/// +/// `curl`, not a link to the releases page, and the difference is not convenience. These +/// binaries are unsigned; macOS and Windows refuse an unsigned executable, but only one the +/// *browser* downloaded — Gatekeeper reads a `com.apple.quarantine` flag and SmartScreen a +/// Mark-of-the-Web, and both are attached by whatever did the downloading. `curl` attaches +/// neither. Putting a download link here would walk somebody into the one warning the whole +/// arrangement avoids. +/// +/// The platform is a guess and says so by naming the file: somebody on an Intel Mac can see +/// `aarch64` and change it. Guessing wrong is a wrong filename, which is visible; asking would +/// be a question before the first useful thing has happened. +function installCommand(): string { + const url = (target: string) => + `https://github.com/QAtlasHub/ssh-browser/releases/latest/download/ssh-browser-${target}`; + const agent = navigator.userAgent; + if (agent.includes("Windows")) { + return [ + `curl -L -o ssh-browser.exe ${url("x86_64-pc-windows-msvc.exe")}`, + ".\\ssh-browser.exe serve", + ].join("\n"); + } + // Apple Silicon by default, because it is most Macs now and the alternative is named in the + // line above it either way. + const target = agent.includes("Mac OS X") + ? "aarch64-apple-darwin" + : "x86_64-unknown-linux-gnu"; + return [`curl -L -o ssh-browser ${url(target)}`, "chmod +x ssh-browser", "./ssh-browser serve"].join( + "\n", + ); +} + function renderOffline(firstRun: boolean): void { const view = clear(); if (!firstRun) { @@ -318,9 +350,15 @@ function renderOffline(firstRun: boolean): void { "half; the daemon runs on your own machine and does the SSH.", ), ); - view.append(node("pre", "cmd", "cargo install ssh-browser\nssh-browser serve")); + view.append(node("pre", "cmd", installCommand())); view.append( - node("p", "note", "Then reload this page. It talks to 127.0.0.1 and to nothing else."), + node( + "p", + "note", + "Then reload this page. It talks to 127.0.0.1 and to nothing else. With cargo, " + + "cargo binstall ssh-browser fetches the same binary; cargo install ssh-browser " + + "builds it, which needs a C compiler.", + ), ); const repo = document.createElement("a"); From e240b1efb3ee18d9f389e5ee3c6d46079292a90f Mon Sep 17 00:00:00 2001 From: Souta Date: Wed, 16 Sep 2026 14:09:03 +0900 Subject: [PATCH 2/3] Use sha256sum, which the Windows runner has The dispatched run built all four targets and then failed on Windows at `shasum: command not found`. Git Bash there ships `sha256sum` and not `shasum`; the three Unix runners have both, so the wrong one of the pair looked fine everywhere it was tried. This is what running the job against an existing tag was for. Discovered in a release it would have been a tag with three binaries and no explanation. Signed-off-by: Souta --- .github/workflows/release-assets.yml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release-assets.yml b/.github/workflows/release-assets.yml index c93e01e..2f8c285 100644 --- a/.github/workflows/release-assets.yml +++ b/.github/workflows/release-assets.yml @@ -95,7 +95,11 @@ jobs: cp "target/$TARGET/release/ssh-browser$EXE" "$out" # Published so somebody can check what they downloaded against what this built, # which is what an unsigned binary can offer instead of a signature. - shasum -a 256 "$out" > "$out.sha256" + # + # `sha256sum`, not `shasum`: the Git Bash on the Windows runner has the first and + # not the second, and this job was dispatched against an existing tag precisely so + # that finding out cost a failed test run rather than a failed release. + sha256sum "$out" > "$out.sha256" cat "$out.sha256" echo "ASSET=$out" >> "$GITHUB_ENV" From 152ad2549fbf7a4d0d3e68017d4ce7436b65e65e Mon Sep 17 00:00:00 2001 From: Souta Date: Wed, 16 Sep 2026 14:13:17 +0900 Subject: [PATCH 3/3] Drop the version from the asset name so latest/download resolves The README says .../releases/latest/download/ssh-browser-x86_64-pc-windows-msvc.exe and the workflow uploaded `ssh-browser-0.5.1-x86_64-pc-windows-msvc.exe`. That URL is the one that never goes stale and it cannot interpolate a version, so it 404s. Found by running the command the README gives instead of reading it. The name loses the version; a pinned download still carries it in the path, `releases/download/v0.5.1/...`, which is where a version belongs. binstall`s `pkg-url` follows. Signed-off-by: Souta --- .github/workflows/release-assets.yml | 10 ++++++++-- crates/ssh-browser/Cargo.toml | 2 +- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/.github/workflows/release-assets.yml b/.github/workflows/release-assets.yml index 2f8c285..2c39c2b 100644 --- a/.github/workflows/release-assets.yml +++ b/.github/workflows/release-assets.yml @@ -90,8 +90,14 @@ jobs: - name: name it after the platform shell: bash run: | - version=$(grep -m1 '^version = ' Cargo.toml | cut -d'"' -f2) - out="ssh-browser-$version-$TARGET$EXE" + # No version in the name, and that is what makes + # `releases/latest/download/ssh-browser-` work -- the URL that never goes + # stale cannot interpolate one. Named with the version, it 404s, which is how this + # was found: by running the command the README gives rather than reading it. + # + # The version is still in the path of a pinned download, + # `releases/download/v0.5.1/...`, which is where it belongs. + out="ssh-browser-$TARGET$EXE" cp "target/$TARGET/release/ssh-browser$EXE" "$out" # Published so somebody can check what they downloaded against what this built, # which is what an unsigned binary can offer instead of a signature. diff --git a/crates/ssh-browser/Cargo.toml b/crates/ssh-browser/Cargo.toml index 3bcf784..f4ada17 100644 --- a/crates/ssh-browser/Cargo.toml +++ b/crates/ssh-browser/Cargo.toml @@ -19,7 +19,7 @@ categories = ["network-programming", "command-line-utilities"] # near-miss falls back to building from source — which is the C compiler this exists to avoid, # arrived at silently. The names come from `release-assets.yml`; if one moves, both move. [package.metadata.binstall] -pkg-url = "{ repo }/releases/download/v{ version }/ssh-browser-{ version }-{ target }{ binary-ext }" +pkg-url = "{ repo }/releases/download/v{ version }/ssh-browser-{ target }{ binary-ext }" # Not an archive. One file is the whole program, and unpacking a tarball that contains a # single binary is a step which exists only because most projects have more than one. pkg-fmt = "bin"