Skip to content

Build and release

Edit on GitHub
Terminal window
bun run build # ./dist/slackcli for the current platform
bun run build:linux # bun-linux-x64 → dist/slackcli-linux
bun run build:macos # bun-darwin-x64 → dist/slackcli-macos
bun run build:windows # bun-windows-x64 → dist/slackcli-windows.exe
bun run build:all # all three

All of them go through scripts/build.ts, a thin wrapper around bun build --compile --minify --bytecode --splitting --format=esm whose real jobs are refusing a Bun older than 1.4.1 (see Why Bun is pinned) and injecting the version:

--define __APP_VERSION__=<version from package.json>

A local (untargeted) build also gets --sourcemap; cross-compiled targets do not. The release workflow calls bun build directly with the same flags, so a change to them must land in both places.

--bytecode ships precompiled JavaScriptCore bytecode, so the binary skips parsing at startup. Measured on Linux x64 with Bun 1.4.1 (#137):

Plain build --bytecode
slackcli --help cold start ~130 ms ~80 ms
bun-linux-x64 78 MB 82 MB
bun-darwin-x64 67 MB 71 MB
bun-windows-x64 83 MB 87 MB

The few extra MB are well inside the 150 MB budget. Two things to know:

  • --bytecode switches the default output format to CommonJS. --format=esm keeps the module semantics of a plain build and is what --splitting requires. --splitting changes nothing today — there are no dynamic import()s to split on — but lets a future lazily-loaded command stay out of the startup path.
  • Bytecode is tied to the JavaScriptCore version of the Bun that emits it, so release binaries must be built by the Bun that CI pins. Bun 1.4.1 is also the first release whose --bytecode cross-compiles to Windows x64.
  • CI only runs the Linux x64 binary, so release.yml runs --version and --help on each binary its own runner can execute (Linux x64, macOS arm64 after signing, Windows x64) before uploading it. The matrix smoke flag is false for cross-arch targets.

src/version.ts resolves the version in one place:

export function getAppVersion(): string {
if (typeof __APP_VERSION__ !== 'undefined') return __APP_VERSION__; // compiled binary
return packageJson.version; // running from source
}

isRunningUnderBun() distinguishes a source run from a compiled binary. It is what disables the self-updater and the “update available” notice during development.

package.json version is the single source of truth. The release workflow refuses to build if the pushed tag disagrees with it.

ci.yml and test.yml run on every push and PR to main. sonar.yml (below) also runs on pushes to main and on same-repo PRs.

ci.yml

  1. workflow-lint — runs the pinned actionlint pre-commit hook over .github/workflows/. This exists because an invalid workflow file is a startup failure: GitHub cannot parse it, so it never resolves the on: triggers, the run carries no logs, and its scheduled runs are never created. stale.yml shipped that way and never ran once. CI runs the same hook id as .pre-commit-config.yaml, so local and CI cannot drift. pre-commit itself is installed from .github/requirements/pre-commit.txt, a hash-locked requirements file (pip install --require-hashes --only-binary :all: -r …) pinning pre-commit and every transitive dependency, so CI never resolves a floating version or builds an sdist. Dependabot bumps it (see dependabot.yml); regenerate the hashes with the command in .github/requirements/pre-commit.in after bumping the pin by hand.
  2. test — install, bun run type-check, bun run build, verify the binary answers --version and --help, and enforce the 150 MB binary size budget.

test.yml — bun test, plus an integration job that builds the binary and smoke-tests it.

Other policy workflows: pr-linked-issue.yml (a PR must link an open issue), signed-commits.yml, and stale.yml.

sonar.yml runs on every push to main and every PR from a branch in this repo. It installs with Bun 1.4.1, runs bun test --coverage --coverage-reporter=lcov, then scans with SonarSource/sonarqube-scan-action (pinned to a commit SHA) and waits for the quality gate (-Dsonar.qualitygate.wait=true). Analysis settings live in sonar-project.properties at the repo root:

  • What is analysed: src/, scripts/, web/ and .github/, so the workflow (githubactions:*) and website rules keep running. *.test.ts files count as tests. Generated web/ output (the synced docs, dist/, the release data) is excluded.
  • What counts toward coverage: only the CLI in src/, read from coverage/lcov.info. This is a scoping decision (#295): web/ and .github/ are not measured by bun test at all, scripts/ is build tooling, and the src/index.ts bootstrap and the type-only src/types/ carry no logic worth a coverage target.
  • The quality gate is SonarCloud’s default “Sonar way”. Its coverage condition is at least 80% on new code, not on the overall figure, so a PR that adds untested lines (typically in src/commands/) fails the gate.
  • What counts as new code. On a PR it is always the PR’s own diff. On main the project uses SonarCloud’s Previous version definition: new code is everything since the analysed version last changed. The workflow sends sonar.projectVersion from package.json (validated as SemVer first), so the period resets when a release PR bumps the version, and main’s gate judges what is going into the next release. Leave the SonarCloud setting on “Previous version”; without a version it would never reset (#306).

Results: shaharia-lab_slackcli on SonarQube Cloud.

  • Non-blocking (phase 1). The scan step has continue-on-error: true, so a failed quality gate or a scanner error shows only as an annotation on the run. A broken SONAR_TOKEN also stays green, so read the step’s output and the dashboard rather than trusting the check colour. Making the check blocking, and then required, are later phases.
  • Fork and Dependabot PRs are skipped, not failed: GitHub gives neither of them Actions secrets, so the job’s if: runs it only for pushes and for same-repo PRs not opened by Dependabot.
  • Secrets and permissions. SONAR_TOKEN (an Actions secret) reaches only the scan step, through env:. The workflow token is contents: read; PR decoration comes from the SonarCloud GitHub App.
  • Automatic Analysis must stay off in the SonarCloud project (Administration → Analysis Method). With it on, the CI scan fails with “You are running CI analysis while Automatic Analysis is enabled”, and continue-on-error hides that.

CI (ci.yml, test.yml, sonar.yml) and the release workflow all pin Bun 1.4.1. Bun 1.3.12 produced corrupt macOS code signatures (oven-sh/bun#29120), and 1.4.1 is the first release where --bytecode works for every target we ship (see Why bytecode), so it is also the minimum for building from source: scripts/build.ts exits with slackcli builds need Bun >= 1.4.1 on an older Bun instead of Bun’s own format must be 'cjs' when bytecode is true error. Bump the pin deliberately, in all three workflow files at once, after reading Bun’s release notes; if the new pin is the new minimum, raise MIN_BUN_VERSION in scripts/build.ts and engines.bun in package.json with it.

It is not cosmetic — it is the constraint that shaped auth login-auto. A bundled Playwright would blow the budget, which is why cdp-client.ts exists as a hand-rolled DevTools Protocol client instead. Anything that would add tens of megabytes needs a different design, not a raised limit.

When releases happen — the weekly schedule, what counts as releasable, out-of-band fixes, versioning and deprecation — is policy, and lives in RELEASING.md. This section covers the mechanics.

Releases are triggered by pushing a v*.*.* tag, and the repo has a /release skill that drives the whole sequence. By hand it is:

  1. Open the release issue and get it labelled ready-for-pr (the constitution applies to releases too).

  2. On a branch: bump version in package.json, promote the Unreleased section of CHANGELOG.md.

  3. Open the PR linking that issue; merge once green.

  4. Push the annotated tag from main:

    Terminal window
    git tag -a v0.9.2 -m "v0.9.2" && git push origin v0.9.2

release.yml then:

  1. verify-version — fails fast if the tag does not match package.json, so binaries can never ship with a version baked in that drifts from source.
  2. build — a matrix of five targets: linux-x64, linux-arm64, darwin-x64, darwin-arm64, windows-x64.
  3. release — collects the artefacts, generates checksums.txt with sha256sum, and publishes a GitHub Release with generated notes. It also exposes the four Unix binaries’ checksums from that file as job outputs.
  4. update-homebrew — mints a short-lived token from a GitHub App scoped to shaharia-lab/homebrew-tap only, and updates the formula there. The formula sha256 values come from the release job’s checksums.txt (nothing is re-downloaded), and the job fails before writing to the tap if any of them is missing or is not a 64-character hex SHA256.
  5. announce-discord — posts one embed to the release channel of the Shaharia Lab Discord: SlackCLI vX.Y.Z linking to the GitHub Release, the release notes, and the Homebrew install hint. See The Discord announcement.

Permissions are least-privilege throughout: the workflow default is contents: read, and only the release job opts into contents: write.

announce-discord runs last, after release and update-homebrew have both succeeded, so the assets and the formula the message points at already exist. Three gates decide whether it runs at all:

  • Not a fork — github.repository must be shaharia-lab/slackcli.
  • This run created the release. Before publishing, the release job checks whether a GitHub Release for the tag already exists and exposes the answer as its created output. “Re-run all jobs” on a finished release finds it in place, so nothing is posted twice. “Re-run failed jobs” after a failed update-homebrew does not re-run release, keeps created=true, and posts once. If the check gets anything but a clear 404 (an API error), it reports created=false: a missed announcement can be posted by hand, a duplicate cannot be taken back.
  • A stable tag. A tag with a SemVer pre-release suffix (it contains -, e.g. v1.0.0-rc.1) is not announced.

The webhook URL is the DISCORD_WEBHOOK repository Actions secret, provisioned by Terraform in the infrastructure repository. It reaches only the posting step, through env:, and is never printed: curl’s own output is discarded and a failure reports the HTTP status alone (000 for a network error). Without the secret the step logs a notice and exits 0.

The payload is built by scripts/release-announcement.ts (tested in scripts/release-announcement.test.ts) and sent with a plain curl, so there is no third-party action. Discord caps an embed description at 4096 characters; longer notes are cut on a line boundary and end with a “Full release notes” link. The payload sets allowed_mentions: { parse: [] }, so an @everyone or a role mention in a PR title cannot ping the server.

It can never fail a release. The job is continue-on-error: when Discord is down or rejects the webhook, the job shows as failed with an ::error:: annotation and the run stays green. To recover a missed announcement, re-run the announce-discord job alone from the run page (or gh run rerun --job <job-id>), or post it by hand. A release whose release job was re-run after it had already created the release is treated as existing and is not announced; post that one by hand too.

If a tag was pushed with the wrong version, fix package.json on main, then delete and re-push the tag — the error message from verify-version says the same.

src/lib/updater.ts backs slackcli update. Four behaviours worth knowing before you change it:

  • It fails closed on verification: the release asset’s digest must be a sha256: digest published by GitHub and must match what was downloaded. A missing or unexpected digest aborts the update rather than installing an unverified binary.
  • It refuses to act when installed via Homebrew (detected from the exec path containing homebrew, Cellar, or linuxbrew) or when running under Bun.
  • It refuses before downloading when isInstallDirWritable() finds the binary’s folder unwritable (replacing the binary renames inside that folder), and getUpdateCommand() then suggests sudo slackcli update — on Windows the command is unchanged and getUpdateHint() adds “from an Administrator terminal”. The notice, update check and the refusal message all build their advice with getUpdateHint(); the notice builds it only when it prints. The Homebrew check comes first. slackcli never elevates itself. On Windows the check creates and removes a probe file, because access(W_OK) there ignores folder ACLs and always reports a folder writable. Tests make a folder unwritable with chmod 0o555 and skip those cases as root or on Windows, where mode bits do not apply.
  • The background check runs at most every 24 hours, caches to ~/.config/slackcli/update-check.json, and prints its notice to stderr on beforeExit — so it never contaminates --json on stdout. Its GitHub lookup is aborted after BACKGROUND_CHECK_TIMEOUT_MS (1.5 s; update / update check use FOREGROUND_CHECK_TIMEOUT_MS, 10 s), a failure is recorded as failedAt and suppresses retries for RETRY_AFTER_FAILURE_MS (1 h), and the notice uses the version fetched in the same run when there is one, the cached one otherwise. It is skipped entirely for update and update check, which report versions themselves, and a successful self-update rewrites the cache with the installed version so the next run does not show a stale notice. It is also skipped entirely (no cache read, no request, no notice) when isUpdateNotifierDisabled() finds SLACKCLI_NO_UPDATE_NOTIFIER or CI set to anything but empty, 0 or false. Tests point the cache at a temp directory with setUpdateCacheDirForTesting(), never at the real ~/.config/slackcli, and pass an explicit env (e.g. {}) to notifyIfUpdateAvailable(): CI runners set CI, so a test that relies on process.env would silently skip its assertions there.

Cookies