Install, verify & build
Everything needed to go from nothing installed to a binary whose signature you have checked yourself — and, if you would rather not trust our binary at all, to one you compiled from source with the same commands CI runs.
It is one page on purpose. Splitting "get it" from "check it" is how the checking step gets skipped, and the checking step is the one that matters.
What is on this page
- Install the CLI — Homebrew, the install script, or a direct download
- Verify what you downloaded — the cosign signature, by hand and automatically
- Confirm the install works
- Container images — pull them anonymously, and pin a version
- Build from source — the exact commands the clean-clone CI job runs
- Staying up to date
Install the CLI
The CLI installs as partyline, with ptln as a short alias symlinked next to it. Everything in
these docs uses ptln; either name works.
macOS and Linux, amd64 and arm64. There is no Windows build — use WSL, or just ssh in as a
session joiner, which needs nothing installed at all.
Homebrew (macOS)
$ brew install partyline-sh/tap/partylineThis is a cask: it installs the already-signed release binaries rather than compiling anything.
Before anything reaches your disk, its preflight block verifies the cosign signature over the
release's checksums.txt and confirms that the sha256 the cask pins is listed in that signed
file — which is what stops the tap repo from being its own trust root. The cask depends on
cosign, so brew fetches it for you. Casks are macOS-only; on Linux use the install script or a
package below.
The install script
$ curl -fsSL https://partyline.sh/install.sh | shIt resolves the latest release, verifies the cosign signature over checksums.txt, checks the
archive's SHA-256 against that now-trusted file, and only then unpacks — the whole of
Verify what you downloaded, done for you.
It needs cosign on your PATH and will not proceed without it: no cosign, a bad signature, a
missing signature asset or a checksum mismatch each abort with nothing installed, name what failed,
and print the manual commands. There is no way to skip the check.
$ brew install cosignPiping a script into a shell is a thing to be uncomfortable about, so read it first if you like — it is the file served at that URL and nothing else:
$ curl -fsSL https://partyline.sh/install.sh | lessWhere it installs: the first writable directory already on your PATH out of /usr/local/bin
and /opt/homebrew/bin, else ~/.local/bin (with a PATH hint printed). Set
PARTYLINE_INSTALL_DIR to choose. It uses sudo only if the chosen directory needs it, and it
warns you if a different partyline is ahead on your PATH.
Linux packages
Download the .deb or .rpm from
the releases page, then:
sudo dpkg -i partyline_*_linux_amd64.deb # Debian / Ubuntu
sudo rpm -i partyline_*_linux_amd64.rpm # Fedora / RHELdpkg and rpm verify nothing about where these came from, so this path does not check the
signature for you — the .deb and .rpm are both listed in the signed checksums.txt, and
Verify it yourself applies to them unchanged. Do that before you install.
Direct download
Grab the archive for your platform from
github.com/partyline-sh/cli/releases, then
extract it and put partyline somewhere on your PATH. The archive also carries ptln-tray (macOS
menu bar companion) and the partyline.1 / ptln.1 man pages.
Nothing verifies this for you either — you are the installer on this path, so run Verify it yourself first. It is four commands.
Verify what you downloaded
This is the section the rest of the page exists for. A release is verifiable in one step and one
step only: a cosign signature over checksums.txt, made by partyline's release workflow. Every
other check on this page hangs off that one.
What a release publishes
| Asset | What it is |
|---|---|
checksums.txt | SHA-256 of every archive, .deb and .rpm in the release |
checksums.txt.sigstore.json | Sigstore bundle — signature, certificate and transparency-log proof in one file |
checksums.txt.sig + checksums.txt.pem | the same signature, detached, for cosign 2.3 and older |
Signing one file is enough because checksums.txt already covers every artifact: trust that file
and the SHA-256 in it transfers that trust to the tarball.
From which release? Signing is wired in
.goreleaser.yamland.github/workflows/release.ymlin this repository, and the release job refuses to finish unless all four assets are attached, the publishedchecksums.txtverifies, and a tampered copy fails to — so the first tagged release after this lands is the first one carrying signatures. Releases cut before it havechecksums.txtand nothing else, and thecurlforchecksums.txt.sigstore.jsonbelow will 404 against them. Check the release page for the signature assets if you are on an older tag.
The signature is keyless. There is no private key anywhere — not in the repo, not in a secret. cosign asks the running release workflow for an OIDC token, Fulcio issues a short-lived certificate bound to that workflow's identity, the signature is recorded in the Rekor transparency log, and the key material is discarded when the process exits. What you check is therefore "this came out of partyline's release pipeline at this tag", not "someone holds a key we published once".
Both install paths verify for you, and fail closed
You do not have to do any of this by hand. install.sh downloads checksums.txt with its
signature, verifies it against the release-workflow identity, verifies the archive's SHA-256 against
that now-trusted file, and only then unpacks. The Homebrew cask does the same in a preflight
block and additionally confirms that the sha256 it pins is listed in the signed checksums.txt.
Every failure aborts with nothing installed: no cosign on the machine, a bad signature, a
signature from another repo or workflow, a missing signature asset, a checksum mismatch. Each names
what failed and prints the manual commands below. There is no flag and no environment variable that
skips verification or picks which cosign runs — a check that can be switched off on error is a
guarantee that does not exist.
Both paths also test the verifier before believing it: after cosign accepts the real
checksums.txt, they hand it the same file with a line appended and require it to reject that. A
cosign that approves everything — a shim ahead of the real one on PATH, a wrapper someone
installed to get past a failing check — fails that negative control and the install stops.
Verify it yourself
Substitute your version for vX.Y.Z and your platform for darwin_arm64 throughout.
tag=vX.Y.Z
base=https://github.com/partyline-sh/cli/releases/download/$tag
curl -fLO $base/partyline_${tag#v}_darwin_arm64.tar.gz
curl -fLO $base/checksums.txt
curl -fLO $base/checksums.txt.sigstore.jsonThen verify the signature. Which shape you use depends on your cosign, because the bundle format
landed mid-flight: 3.x reads the bundle directly, 2.4–2.x needs --new-bundle-format, and
2.3 and older have no bundle support and want the detached signature instead. (On 3.x the older
flags still function but are deprecated and no longer listed in --help, which is exactly how
install.sh picks a shape — it reads the flags your cosign advertises rather than parsing a version
string.)
# cosign 3.x
cosign verify-blob checksums.txt \
--bundle checksums.txt.sigstore.json \
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
--certificate-identity https://github.com/partyline-sh/partyline/.github/workflows/release.yml@refs/tags/vX.Y.Z
# cosign 2.4-2.x — the same command, plus --new-bundle-format
cosign verify-blob checksums.txt \
--bundle checksums.txt.sigstore.json --new-bundle-format \
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
--certificate-identity https://github.com/partyline-sh/partyline/.github/workflows/release.yml@refs/tags/vX.Y.Z
# cosign 2.3 and older — detached signature + certificate
curl -fLO $base/checksums.txt.sig
curl -fLO $base/checksums.txt.pem
cosign verify-blob checksums.txt \
--signature checksums.txt.sig --certificate checksums.txt.pem \
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
--certificate-identity https://github.com/partyline-sh/partyline/.github/workflows/release.yml@refs/tags/vX.Y.ZYou want exactly this, and exit status 0:
Verified OKTwo details in that command are the whole check, so do not paste past them:
--certificate-identitymust namepartyline-sh/partyline'srelease.ymlat your tag. The identity is the source repo, notpartyline-sh/cliwhere the assets live. A signature made by any other repo, any other workflow, or the same workflow at a different tag is a valid signature over someone else's release — and pinning the identity is what rejects it.--certificate-oidc-issuermust be GitHub's. Without it, a certificate from any issuer Sigstore trusts would be accepted.
Only once that printed Verified OK, check the archive against the now-trusted file:
shasum -a 256 -c checksums.txt --ignore-missing # macOS
sha256sum -c checksums.txt --ignore-missing # Linux--ignore-missing is there because you downloaded one archive and checksums.txt lists them all.
You want partyline_…tar.gz: OK.
Prove your cosign is really checking
A verifier that says Verified OK to everything is indistinguishable from a real one on the happy
path. Both install paths run this control automatically; run it yourself and you have the same
assurance they do:
cp checksums.txt tampered.txt
echo "0000000000000000000000000000000000000000000000000000000000000000 evil.tar.gz" >> tampered.txt
cosign verify-blob tampered.txt \
--bundle checksums.txt.sigstore.json \
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
--certificate-identity https://github.com/partyline-sh/partyline/.github/workflows/release.yml@refs/tags/vX.Y.ZThis must fail. If it prints Verified OK, the cosign on your PATH is not verifying
anything and the check above proved nothing. The release workflow makes the same assertion against
the published assets on every release, so a release that cannot be tampered-detected never ships.
If you do not have cosign
Install it — it is a single binary, and it is the only thing on this page that establishes where your download came from:
brew install cosign
# or your distro's package, or https://docs.sigstore.dev/cosign/system_config/installation/The Homebrew cask depends on cosign, so brew install partyline-sh/tap/partyline fetches it for
you. install.sh will not: if cosign is missing it prints these commands and stops, rather than
installing something it could not check.
A SHA-256 check on its own is not a fallback for the signature. You can run it —
shasum -a 256 -c checksums.txt --ignore-missing— and it will catch a corrupt or truncated download. It cannot tell you the file came from us,
because whoever could replace the tarball could replace checksums.txt in the same breath. That is
precisely the gap the signature closes, which is why nothing here treats "checksums only" as
verified. If you genuinely cannot run cosign, build from source instead.
macOS: the Apple signature, as well
Independently of cosign, the macOS binaries are signed with an Apple Developer ID certificate and notarized. After extracting:
codesign --verify --strict --verbose=2 ./partyline
codesign -dv --verbose=4 ./partyline 2>&1 | grep AuthorityThe first prints valid on disk / satisfies its Designated Requirement and exits 0. The second
names who signed it:
Authority=Developer ID Application: Darcy Reno (ZPQJ3BL848)
Authority=Developer ID Certification Authority
Authority=Apple Root CATwo results that look like failures and are not, because a bare executable is not a bundle:
xcrun stapler validatesays "does not have a ticket stapled". A ticket can only be stapled to a bundle; Gatekeeper checks a binary like this against Apple's service online instead.spctl -a -t execsays "rejected (the code is valid but does not seem to be an app)".codesign --verifyis the check that applies to a CLI binary.
install.sh does not strip com.apple.quarantine — it used to, which quietly disabled
Gatekeeper on exactly the binary you are least able to inspect. Apple's check and ours now both
apply.
Linux binaries carry no OS-level signature; there is no Gatekeeper equivalent, and the cosign signature above is what establishes provenance on every platform.
Confirm the install works
ptln version # the installed version
ptln doctor # is this machine set up to plan and run work?
ptln man # the manual page
ptln help # every commandptln doctor is the useful one: it checks sign-in, this machine, the repo you are standing in, its
context thread and its project, and every failing line prints the exact command that fixes it.
It is read-only and safe to run anywhere.
ptln man install puts the man page on your manpath so man ptln works; the installer already
attempts this, silently.
You do not need an account for any of the above, or for hosting a terminal
session. Sign in with ptln login when you want the control plane.
Container images
The two images the stack is made of are public packages on GHCR: no docker login, no personal
access token, no GitHub account.
| Service | Image |
|---|---|
web | ghcr.io/partyline-sh/partyline-web — the Next.js control plane |
relay | ghcr.io/partyline-sh/partyline-relay — the blind E2EE session relay |
Everything else in deploy/stack/docker-compose.yml is a stock upstream image (postgres:16,
postgrest/postgrest:v12.2.3, redis:7-alpine, caddy:2-alpine) from its own public registry.
docker pull ghcr.io/partyline-sh/partyline-web:latest
docker pull ghcr.io/partyline-sh/partyline-relay:latestTo prove no credential is involved — rather than trusting that your Docker config happens to be
empty — point Docker at a throwaway config directory. This is what CI does, because docker logout
would log the whole machine out as a side effect of a test:
docker --config "$(mktemp -d)" pull ghcr.io/partyline-sh/partyline-web:latestPin a version
:latest is whatever production is running right now. It moves, on our schedule, without
telling you. Fine for a first look; not what you want holding a team's data.
| Tag | Means | Moves? |
|---|---|---|
:latest | whatever production is running now | yes, on every production deploy |
:prod-<sha> | the build promoted from commit <sha> on main | no |
@sha256:<digest> | those exact bytes | never, by construction |
Pin by digest. A tag is a pointer someone can repoint; a digest is the content. It is what our own production deploy resolves to before it ships anything, for exactly this reason.
docker buildx imagetools inspect ghcr.io/partyline-sh/partyline-web:latest --format '{{.Manifest.Digest}}'
# or, without buildx:
docker manifest inspect -v ghcr.io/partyline-sh/partyline-web:latest | headThen set it in the .env beside the compose file. Two knobs, not interchangeable:
# pin to an immutable tag
WEB_TAG=prod-1a2b3c4
RELAY_TAG=prod-1a2b3c4
# ...or pin to the bytes. WEB_IMAGE replaces the whole reference, which is the only way to
# express a digest: "@" is not legal in the tag position, so WEB_TAG cannot carry one.
WEB_IMAGE=ghcr.io/partyline-sh/partyline-web@sha256:<digest>
RELAY_IMAGE=ghcr.io/partyline-sh/partyline-relay@sha256:<digest>WEB_IMAGE / RELAY_IMAGE are also the seam to use if you mirror these images into a registry of
your own. Confirm what you actually pinned before bringing the stack up:
docker compose -f deploy/stack/docker-compose.yml config | grep 'image:'Upgrading is then a deliberate act — change the value, docker compose pull && docker compose up -d
— and rolling back is the same edit with the previous value.
Not yet flipped — the pulls above fail today. A GHCR package created by a workflow in a private repo defaults to private, and making it public is a manual, per-package UI action: GitHub exposes no REST route and no GraphQL mutation for it, so no workflow and no token can do it for us. At this page's
verified_atboth packages are still private and an anonymous pull returnsdenied. That is our outstanding step, not anything wrong with your setup.The check for it ships in this repo and is credential-free by construction, so it cannot pass on an ambient login. Run it yourself, or read the weekly CI job that runs it:
./scripts/public-images.shIt names the exact one-time fix and asserts that only
partyline-webandpartyline-relayare world-readable — publishing one package too many is silent and unrepairable. Details indeploy/stack/README.md.
Build from source
Both halves build from a clean checkout and nothing else, and CI proves it: the
Source build (clean clone) job in .github/workflows/ci.yml clones the repo from GitHub into an
empty directory with cold Go and Docker caches and runs exactly the commands below. An
instruction here that is missing, stale, or only works because of a file on somebody's laptop turns
that job red — so this section is executable documentation, not a description of one.
What you need first: Go (the version in go.mod; the toolchain fetches it if yours is older),
Docker for the web image, and Node 22 only if you want to run the web app directly rather
than as an image.
git clone https://github.com/partyline-sh/partyline.git
cd partyline
go build -o partyline . # the CLI + daemon: one binary, repo root is package main
./partyline version # prints "dev" from a source build
docker build -t partyline-web ./web # the control plane image, the same build the deploy shipsdev is the version stamp on any binary not built by the release pipeline (releases pass
-X main.version=<tag>). It is load-bearing, not cosmetic: a dev build never sends telemetry
and never runs an update check, both by construction rather than by configuration. See
Telemetry.
ptln — the command every doc uses — is just a symlink to the partyline binary. The installer
makes it; from a source build you make it yourself:
$ ln -s "$PWD/partyline" ~/.local/bin/ptlnmake build is the same Go build, into bin/ptln. The web image is self-contained — it runs
npm ci and npm run build inside its builder stage — so building it needs no local Node. To work
on the web app instead of packaging it, cd web && npm install.
The one thing CI does differently
The source-build job runs on a self-hosted runner that keeps state between runs, so it starts each build cold and builds the image as:
docker build --no-cache --pull -t partyline-web ./webYou do not need those two flags — a fresh machine has nothing to reuse anyway. They are named here because they are the only difference between the job and the commands above, and because flags that merely remove reuse cannot make a broken build pass. What the job does not prove is that a bare machine has Go, Docker and git in the first place; those are the prerequisites listed above.
What is public, and what is not
The CLI and the relay are open source (MIT) and mirrored to
github.com/partyline-sh/cli on every release — a
fresh-history snapshot of the Go client, including the crypto and the relay. Building that clone
is the same go build -o partyline .; go vet ./... passes there too. go test ./... does not
— two packages (internal/surfacescan, internal/surfacegen) assert against the web/ tree and
the migrations, neither of which is published, so they fail there and pass in the monorepo.
Known gap, fixed but not yet re-synced. The mirror used to prune all of
docs/, which tookdocs/partyline.1with it — a filemanpage.goembeds — so the published tree failed to compile at all withpattern docs/partyline.1: no matching files found. The mirror script now keeps the two man pages; until the next release re-syncs it,touch docs/partyline.1 docs/ptln.1in the clone and the build succeeds.
The clone URL in the block above is the monorepo, which is what the source-build job clones and what the web-image command needs. It is private today; the public mirror is the CLI half.
Run the gates CI runs
One Makefile at the repo root, so a contributor and CI run the same commands. make check-go is
the Go job in .github/workflows/ci.yml:
gofmt -l . # must print nothing
go build ./...
go vet ./...
go test ./...
GOOS=linux GOARCH=amd64 go vet ./... # the fleet runs on Linux
go run ./cmd/gc-surface -check # generated artifacts match their declarationsThe cross-vet line is not decoration: a darwin-only symbol in an untagged file compiles fine on a Mac and breaks every Linux node, which is how the root package once sat un-buildable on Linux until CI first vetted it.
CI runs more checks on top of that, each runnable here:
go run ./cmd/gc-surface -audit -strict # new surface must be claimed by a doc
./scripts/secret-scan.sh selftest # the scanner refuses to report if its own selftest fails
./scripts/secret-scan.sh tree
./scripts/public-images.sh # the published images are anonymously pullableThe docs audit is what keeps this page honest: a command, route or table merged with no doc claiming
it fails the build. This page carries its own covers: claim and a verified_at: stamp so the audit
counts it and can tell you when it has gone stale.
The web app directly
Node 22 — the same major the image uses.
cd web
npm ci
npx tsc --noEmit
npx vitest run
npm run buildnpm run build is not optional and is not interchangeable with tsc. tsc cannot see the
Next.js server/client boundary; a client component importing a value from a server-only module
type-checks clean and dies in the build. That exact failure broke every deploy for six merges before
a real next build ran in CI.
src/lib/docs-search-index.json is generated, not committed — npm run dev and npm run build
both rebuild it first, so a fresh clone produces it before anything reads it. It used to be tracked,
and because every branch regenerated it, it became the single largest source of merge conflicts in
the repo: branches collided on a file no human has ever read.
make check-web is those last three plus the discard. make check runs the Go and web halves
together.
The relay image
docker build -f relay/Dockerfile -t partyline-relay .Both images build with no arguments and no secrets. That is deliberate: the web image carries no
environment-identifying build arg, because production is a promotion of the exact image staging
validated, and an image with a hostname compiled into it could only ever run in one place.
SENTRY_AUTH_TOKEN is the one build arg accepted, it is optional (it only uploads source maps), and
it is never copied into the final image.
Staying up to date
$ ptln upgradeIt upgrades the way you installed: brew upgrade partyline for a Homebrew keg, otherwise it re-runs
the install script — which re-verifies the signature and the checksum, so an upgrade is checked
exactly as hard as the first install was. ptln update is an alias.
Fleet auto-update is wired, and it is opt-in per machine. A daemon node that has
run ptln daemon autoupdate on checks the public version endpoint every 6 hours and updates itself
only when all of these hold: the operator opted in, update checks are not disabled, the daemon is
service-managed (something can restart it), no run is in flight, and the published version is
strictly newer. The "update now" button on the fleet page sends a one-shot nudge that honours the
same guards. The control plane never sends a URL, an artifact or a command — every node converges on
the same public release, so a compromised control plane still cannot make one machine run something
bespoke.
Treat that opt-in as what it is: a decision to let a machine install whatever is published on
partyline-sh/cli, unattended. It is off by default for that reason — but because it re-runs the
same installer, every unattended update still has to clear the same signature check, so the release
workflow's identity remains the thing being trusted rather than the release page's contents.
To silence the "a new version is available" notice without changing anything else, set
PARTYLINE_NO_UPDATE_CHECK=1 (or the cross-tool NO_UPDATE_NOTIFIER=1) — see
Telemetry.