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

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/partyline

This 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

It 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 cosign

Piping 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:

Where 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 / RHEL

dpkg 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

AssetWhat it is
checksums.txtSHA-256 of every archive, .deb and .rpm in the release
checksums.txt.sigstore.jsonSigstore bundle — signature, certificate and transparency-log proof in one file
checksums.txt.sig + checksums.txt.pemthe 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.yaml and .github/workflows/release.yml in this repository, and the release job refuses to finish unless all four assets are attached, the published checksums.txt verifies, and a tampered copy fails to — so the first tagged release after this lands is the first one carrying signatures. Releases cut before it have checksums.txt and nothing else, and the curl for checksums.txt.sigstore.json below 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.json

Then 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.Z

You want exactly this, and exit status 0:

Verified OK

Two details in that command are the whole check, so do not paste past them:

  • --certificate-identity must name partyline-sh/partyline's release.yml at your tag. The identity is the source repo, not partyline-sh/cli where 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-issuer must 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.Z

This 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 Authority

The 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 CA

Two results that look like failures and are not, because a bare executable is not a bundle:

  • xcrun stapler validate says "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 exec says "rejected (the code is valid but does not seem to be an app)". codesign --verify is 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 command

ptln 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.

ServiceImage
webghcr.io/partyline-sh/partyline-web — the Next.js control plane
relayghcr.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:latest

To 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:latest

Pin 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.

TagMeansMoves?
:latestwhatever production is running nowyes, on every production deploy
:prod-<sha>the build promoted from commit <sha> on mainno
@sha256:<digest>those exact bytesnever, 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 | head

Then 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_at both packages are still private and an anonymous pull returns denied. 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.sh

It names the exact one-time fix and asserts that only partyline-web and partyline-relay are world-readable — publishing one package too many is silent and unrepairable. Details in deploy/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 ships

dev 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/ptln

make 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 ./web

You 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 took docs/partyline.1 with it — a file manpage.go embeds — so the published tree failed to compile at all with pattern 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.1 in 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 declarations

The 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 pullable

The 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 build

npm 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 upgrade

It 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.