All sessions must follow the Bitrise CLI patterns guide — the team's
internal research doc on CLI conventions, cross-referenced against gh,
glab, bk, gcloud, aws, heroku, clig.dev, and the
Heroku CLI Style Guide.
(Bitrise maintainers: it lives in the team Confluence space.)
That doc is the source of truth for CLI conventions: command structure,
noun/verb naming, output formats, flag conventions, config precedence, help
format, stdout/stderr discipline, exit codes. Re-read it before changing any
user-facing surface (flags, command names, error messages, help text) or
adding new commands. If a proposed change conflicts with the guide, raise
the conflict — don't go around it. The locked-in conventions below capture the
guide's decisions that this codebase depends on; when the guide isn't handy,
mirror what gh does — it's the closest-spirit reference CLI for our use case.
bitrise-cli is a CLI for Bitrise platform resources (builds, apps,
workflows). cmd/ handlers call into service types in internal/build,
internal/app, etc., which in turn call the real Bitrise API through the
HTTP client in bitriseapi/. The layering (below) is strict: keep HTTP and
business logic out of cmd/.
The canonical binary name is bitrise-cli. br is documented as an
optional shell alias / symlink, NOT shipped as the binary name. The
patterns guide flags a real collision with [broot]'s br shell function;
do not rename the binary to br without a team decision.
cmd/ cobra presentation only: flag parsing, output formatting,
calling into services. NO business logic, NO HTTP.
internal/build service layer for build operations
internal/app service layer for app + workflow operations
internal/auth Auth file (auth.yaml): the access token only
internal/config Config + Path/Load/Save, LoadDir, Resolve, ctx helpers
internal/output Format + generic Render; Human and JSON formats
bitriseapi/ HTTP client; called by the internal services.
cmd handlers do exactly: parse flags → call service method → render result.
The services hold a *bitriseapi.Client and own all HTTP/business logic, so
new commands extend the services without touching the cmd layer's shape.
- Output format flag:
--output human|json,-o.humanis the default. JSON output is a stable contract — additive changes only; no breaking renames without a major version bump. - stdout vs stderr: stdout carries the answer (data, JSON, table rows). stderr carries diagnostics, confirmations, progress. JSON mode never mixes diagnostics into stdout. Errors via cobra's RunE go to stderr.
- Output scheme: colors, symbols (
✓/✗/→), table layout, key/value patterns, ErrWriter usage, and JSON contracts are documented indocs/output-scheme.md. Follow it when adding or changing any human-readable output. - Config precedence (highest to lowest):
- CLI flag (
--outputfolded in bypersistentPreRun; per-command flags like--appare layered in the command handler itself) - Environment variables (
BITRISE_TOKEN,BITRISE_APP_ID,BITRISE_WORKSPACE_ID,BITRISE_OUTPUT,BITRISE_API_BASE_URL) - Per-directory file:
.bitrise-cli.ymlin CWD or any ancestor - Global file:
$XDG_CONFIG_HOME/bitrise/config.yaml(falls back to~/.config/bitrise/config.yaml) - Auth file (token + type only):
$XDG_CONFIG_HOME/bitrise/auth.yaml - Built-in defaults
- CLI flag (
- Token storage:
bitrise-cli auth loginwrites the token toauth.yaml. In an interactive terminal it defaults to browser OAuth, storing a managed PAT that auto-refreshes (--with-tokenpastes/pipes a token,--emailmints one via email/password). PAT and WAT tokens are stored identically (same wire format — no type field). Resolve order for tokens: env > auth.yaml. - File perms: both
config.yamlandauth.yamlare 0600; parent dir 0700. - Bitrise verbs:
build trigger(not create);build abort(not cancel) when added;build rerunfor re-runs;viewis the detail verb. - Singular nouns:
app,build,workflow— never plural. - Identifier term:
ID, neverslug(user-facing). Every user-visible surface — help text, flag/arg metavars (APP_ID,BUILD_ID), output labels (ID:) and table headers (ID), config keys (app_id,default_workspace_id), env vars (BITRISE_APP_ID), and--output jsonfield names (id,app_id,build_id,workspace_id) — says ID. The Bitrise API calls theseslugon the wire; that term lives only in thebitriseapi/layer and in internal Go identifiers (e.g.AppSlug,OrgSlug) — it must never reach the user. - Workspace, never
organization/org/owner(user-facing). The entity that owns an app is a workspace everywhere a user sees it: the--workspaceflag, theWORKSPACEcolumn, theWorkspace:label. "Organization"/"org" is obsolete. An app's owner is always a workspace (user-owned apps are unsupported), so apps use "workspace", not "owner". (rde template'sOwnercolumn is the template creator's email — a different concept — and keeps "Owner".) PROJECT_TYPE, notPROJECT: the platform column/label (ios, android, …) is "Project type" (PROJECT_TYPEas an ALL-CAPS table header,Project type:as a key/value label). This reflects the API'sproject_typefield — keep it in internal code but don't use "project" as a standalone user-facing term for an app.- Stdin via
-:bitrise-cli config set token -reads from stdin so secrets stay out of shell history. Apply this pattern to any new secret-accepting command. -q/--quietsuppresses non-error stderr ("Saved output", etc.). Errors and primary stdout output ignore it.- Update checks: best-effort "new release available" notice on stderr
(never stdout/JSON), at most once per 24h against GitHub's public Releases API
— nothing is sent to Bitrise. Gated to released builds on an interactive TTY;
skipped in CI, under
-q, in--output json, and viaBITRISE_CLI_NO_UPDATE_NOTIFIER. Mechanics ininternal/update; the when-to-show policy isshouldCheckForUpdateincmd/. Full spec:docs/output-scheme.md§11.
These are listed in the patterns guide as standard features but are intentionally out of scope right now. Don't reopen the discussion as part of an unrelated change:
bitrise-cli apiraw HTTP wrapper--json fieldsprojection +--jqexpression--dry-runfor mutating commands- Workspace concept (
workspace use,--workspace) - Confirmation prompts on destructive ops (no destructive ops exist yet)
- OS-keychain token storage (currently in
auth.yaml0600) - PAT vs WAT type tagging — they have identical wire format, so we store them as one opaque token. Add a type field back if/when cross-workspace operations gain WAT-aware warnings.
bitrise.yml-based context auto-detection- Telemetry, plugin system,
initwizard - Per-directory config writing via
bitrise-cli config set(currently set/unset only modify the global file; per-dir is hand-edited)
Prefer the make targets — they're the source of truth and also what CI runs:
make build— binary lands at the repo root, gitignoredmake tidy—go mod tidy+ ensuresgo.mod/go.sumare unchangedmake fmt— formatting check (must produce no output)make vet— static analysismake lint— runs golangci-lint viago run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@<pinned-version>. Version is pinned in theMakefile; no separate install step needed. The compiled binary is cached inGOCACHE, so subsequent runs are fast.make lint-fix— same asmake lintbut applies auto-fixes (--fix).make test—go test -race -count=1 -timeout=5m ./...make docs— regeneratedocs/cli/(markdown reference, one file per command, rendered from the cobra command tree viatools/gendocs). Run this whenever a command, flag, or help text changes — CI runsmake docs-checkand fails if the committed files drift.- Run the full quality gate via
bitrise run test - When adding tests, put them in the same package as the file under test
go.modis at module pathgithub.com/bitrise-io/bitrise-cli
Always run make lint fix and fix all issues before reporting work as
complete. go vet alone is not sufficient.
Every fmt.Fprintf / fmt.Fprintln / fmt.Fprint return value must be
captured. Two patterns cover all cases:
Single write with early return — capture inline:
_, err := fmt.Fprintln(w, "message")
return errMultiple sequential writes — use cmdutil.NewErrWriter, check once at the end:
ew := cmdutil.NewErrWriter(w) // or NewErrWriter(tabwriter)
ew.Ln("header")
ew.F("row %s\n", value)
return ew.ErrNever write fmt.Fprintln(w, x) or fmt.Fprintf(w, ...) without capturing
the return. The linter will reject it every time.
cmd.version, cmd.commit, and cmd.buildNumber are package-level vars
so CI can inject real values via -ldflags:
go build -ldflags "-s -w \
-X github.com/bitrise-io/bitrise-cli/cmd.version=X.Y.Z \
-X github.com/bitrise-io/bitrise-cli/cmd.commit=$GIT_SHA \
-X github.com/bitrise-io/bitrise-cli/cmd.buildNumber=$BUILD_NO"
buildNumber is the CI build number (from $BITRISE_BUILD_NUMBER, injected
by the release pipeline) so a published binary can be traced back to the
build that produced it. It's empty for dev builds and omitted from version
output when empty.
When ldflags aren't set, runtime/debug.ReadBuildInfo() fills in
vcs.revision and vcs.time so bitrise-cli version still has commit info.
Releases are tag-triggered: push a semver tag (vX.Y.Z) and the release
workflow in bitrise.yml runs GoReleaser, which cross-compiles every
supported platform and publishes a draft GitHub release (archives +
checksums.txt). A human reviews the generated notes and clicks publish.
.goreleaser.yamlis the single source of truth for release builds (platform matrix, ldflags). Keep its ldflags in sync with the Makefile's dev-buildLDFLAGS.make release-checkvalidates the config;make release-snapshotbuilds all platforms intodist/without tagging or publishing. Thesnapshotworkflow inbitrise.ymldoes the same on CI for ad-hoc binaries.- GoReleaser is version-pinned in the
Makefileand run viago run, like golangci-lint. - The CI release needs a
GITHUB_TOKENsecret withcontents:write(configured on the Bitrise app, not in the repo).
- Cobra auto-binds
-vto--version. The patterns guide reserves-vfor--verbose. Reclaim it when adding--verbose. - A malformed config file (global or per-dir) makes every command fail,
including
bitrise-cli config list/set/unset. Recovery is hand-editing or deleting the file. Aconfig resetescape hatch is a reasonable follow-up.
The command overview in README.md (between the commands-overview
markers) and the per-command pages in docs/cli/ are generated by
tools/gendocs — never edit them by hand. After any change to a command,
flag, or help text, run make docs and commit the result; CI runs
make docs-check and fails on drift.
Follow the locked-in conventions above and the patterns guide they come from.
If neither covers the case, mirror what gh does — that's the closest-spirit
reference CLI for our use case.