Here is the most important line in a deploy job:
URL=$(subnomic sites deploy --prod)
It only works if the command prints exactly one thing to standard output — the URL. Everything else, from hashing to the upload bar, goes to standard error.
stdout is a contract
Output meant for a person and output meant for a program travel on different streams for a reason. The moment a CLI prints “Deploying… done!” to stdout, every script that captures it ends up with a progress log in a variable. That is why a friendly extra line on stdout in subnomic sites deploy would be a breaking change, not a cosmetic one.
Pretty on a terminal, plain in a pipe
The login form, pickers, spinners and upload bar appear only when stdin and stderr are both terminals, and they draw on stderr. Redirect either one and every command falls back to plain tab-separated tables on stdout and plain progress on stderr — and a picker that would have asked a question refuses instead of waiting forever for an answer that isn’t coming.
$ subnomic servers | awk '{print $1}'
$ subnomic sites ls --json
Structured output is opt-in, never the default. Colour honours NO_COLOR, decided per stream. Cancelling a picker exits with status 130 — the shell’s own interrupt code — rather than pretending an error happened.
CI without a login
A runner has no browser, so it authenticates with a workspace API key in SUBNOMIC_TOKEN. Give a deploy job only the sites.deploy scope: it can publish and roll back, and it cannot delete the site it publishes to.
- run: curl -fsSL https://get.subnomic.com | bash
- id: deploy
run: echo "url=$(subnomic sites deploy --prod)" >> "$GITHUB_OUTPUT"
env:
SUBNOMIC_TOKEN: ${{ secrets.SUBNOMIC_TOKEN }}
SUBNOMIC_API: https://api.subnomic.com
Small promises
None of this is clever. It is a handful of rules — one stream for results and one for people, refuse instead of hang, opt in to structure — kept consistently, so that the pipeline you write today keeps working.
Every command is in the CLI reference.