Building
Section titled “Building”bun run build # ./dist/slackcli for the current platformbun run build:linux # bun-linux-x64 → dist/slackcli-linuxbun run build:macos # bun-darwin-x64 → dist/slackcli-macosbun run build:windows # bun-windows-x64 → dist/slackcli-windows.exebun run build:all # all threeAll 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.
Why bytecode
Section titled “Why bytecode”--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:
--bytecodeswitches the default output format to CommonJS.--format=esmkeeps the module semantics of a plain build and is what--splittingrequires.--splittingchanges nothing today — there are no dynamicimport()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
--bytecodecross-compiles to Windows x64. - CI only runs the Linux x64 binary, so
release.ymlruns--versionand--helpon each binary its own runner can execute (Linux x64, macOS arm64 after signing, Windows x64) before uploading it. The matrixsmokeflag isfalsefor cross-arch targets.
Versioning
Section titled “Versioning”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
workflow-lint— runs the pinnedactionlintpre-commit hook over.github/workflows/. This exists because an invalid workflow file is a startup failure: GitHub cannot parse it, so it never resolves theon:triggers, the run carries no logs, and its scheduled runs are never created.stale.ymlshipped that way and never ran once. CI runs the same hook id as.pre-commit-config.yaml, so local and CI cannot drift.pre-commititself is installed from.github/requirements/pre-commit.txt, a hash-locked requirements file (pip install --require-hashes --only-binary :all: -r …) pinningpre-commitand every transitive dependency, so CI never resolves a floating version or builds an sdist. Dependabot bumps it (seedependabot.yml); regenerate the hashes with the command in.github/requirements/pre-commit.inafter bumping the pin by hand.test— install,bun run type-check,bun run build, verify the binary answers--versionand--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.
SonarQube Cloud
Section titled “SonarQube Cloud”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.tsfiles count as tests. Generatedweb/output (the synced docs,dist/, the release data) is excluded. - What counts toward coverage: only the CLI in
src/, read fromcoverage/lcov.info. This is a scoping decision (#295):web/and.github/are not measured bybun testat all,scripts/is build tooling, and thesrc/index.tsbootstrap and the type-onlysrc/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
mainthe project uses SonarCloud’s Previous version definition: new code is everything since the analysed version last changed. The workflow sendssonar.projectVersionfrompackage.json(validated as SemVer first), so the period resets when a release PR bumps the version, andmain’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 brokenSONAR_TOKENalso 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, throughenv:. The workflow token iscontents: 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-errorhides that.
Why Bun is pinned
Section titled “Why Bun is pinned”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.
Why the 150 MB budget matters
Section titled “Why the 150 MB budget matters”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.
Releasing
Section titled “Releasing”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:
-
Open the release issue and get it labelled
ready-for-pr(the constitution applies to releases too). -
On a branch: bump
versioninpackage.json, promote the Unreleased section ofCHANGELOG.md. -
Open the PR linking that issue; merge once green.
-
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:
verify-version— fails fast if the tag does not matchpackage.json, so binaries can never ship with a version baked in that drifts from source.build— a matrix of five targets:linux-x64,linux-arm64,darwin-x64,darwin-arm64,windows-x64.release— collects the artefacts, generateschecksums.txtwithsha256sum, and publishes a GitHub Release with generated notes. It also exposes the four Unix binaries’ checksums from that file as job outputs.update-homebrew— mints a short-lived token from a GitHub App scoped toshaharia-lab/homebrew-taponly, and updates the formula there. The formulasha256values come from thereleasejob’schecksums.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.announce-discord— posts one embed to the release channel of the Shaharia Lab Discord:SlackCLI vX.Y.Zlinking 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.
The Discord announcement
Section titled “The Discord announcement”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.repositorymust beshaharia-lab/slackcli. - This run created the release. Before publishing, the
releasejob checks whether a GitHub Release for the tag already exists and exposes the answer as itscreatedoutput. “Re-run all jobs” on a finished release finds it in place, so nothing is posted twice. “Re-run failed jobs” after a failedupdate-homebrewdoes not re-runrelease, keepscreated=true, and posts once. If the check gets anything but a clear 404 (an API error), it reportscreated=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.
The self-updater
Section titled “The self-updater”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, orlinuxbrew) or when running under Bun. - It refuses before downloading when
isInstallDirWritable()finds the binary’s folder unwritable (replacing the binary renames inside that folder), andgetUpdateCommand()then suggestssudo slackcli update— on Windows the command is unchanged andgetUpdateHint()adds “from an Administrator terminal”. The notice,update checkand the refusal message all build their advice withgetUpdateHint(); 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, becauseaccess(W_OK)there ignores folder ACLs and always reports a folder writable. Tests make a folder unwritable withchmod 0o555and 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 onbeforeExit— so it never contaminates--jsonon stdout. Its GitHub lookup is aborted afterBACKGROUND_CHECK_TIMEOUT_MS(1.5 s;update/update checkuseFOREGROUND_CHECK_TIMEOUT_MS, 10 s), a failure is recorded asfailedAtand suppresses retries forRETRY_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 forupdateandupdate 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) whenisUpdateNotifierDisabled()findsSLACKCLI_NO_UPDATE_NOTIFIERorCIset to anything but empty,0orfalse. Tests point the cache at a temp directory withsetUpdateCacheDirForTesting(), never at the real~/.config/slackcli, and pass an explicitenv(e.g.{}) tonotifyIfUpdateAvailable(): CI runners setCI, so a test that relies onprocess.envwould silently skip its assertions there.