Skip to content
⌘K

Guides

npm and Homebrew

Publish every official release to npm and Homebrew under your own names, with no tokens to manage.

Okibi can publish each official release of your CLI to npm and Homebrew. Your users install it the way they install other tools, and they get the same build as the Okibi installer:

Terminal
npm install -g @acme/my-cli
brew install acme/tap/my-cli

Everything is published from your own GitHub account under names you own. npm trusts a workflow in your CLI's repository, and the Homebrew formula lives in your own tap, so there is no npm token for you to store or rotate.

Before you start

  • Your code is in your GitHub account. Okibi can't publish from an Okibi-hosted repository, because npm ties each package to the repository that publishes it. To move the code, open Settings > Where it lives, click Move to GitHub next to Code, and pick Your GitHub.
  • The official release is public. npm and Homebrew are public, so they only get a release whose Access is Anyone with the install line (Pro plan). See Releases and install.
  • For npm: an npm account, an npm organization if you want a scoped name like @acme/my-cli (organizations are free for public packages), and npm 11.15 or later on your machine.

Only workspace owners and admins can change these settings.

Turn it on

  1. Open the project's Settings. In Where it lives, click Manage next to npm and Homebrew.
  2. Check the Description and Homepage. They show on npm and in brew info. An empty homepage uses your repository.
  3. Turn on Publish to npm and enter the Package name, for example @acme/my-cli or my-cli.
  4. Turn on Publish to Homebrew.
  5. Click Save.

Homebrew needs nothing else. npm needs the one-time setup below.

Set up npm (once)

  1. Merge the setup pull request. Okibi opens it in your CLI's repository. It adds three files and nothing else, so merging it doesn't release a new version:

    • .github/workflows/release-npm.yml: the release workflow
    • .github/npm/package.mjs: turns a release's binaries into npm packages
    • .github/npm/config.json: your package name and the key that verifies each release
  2. Publish the first version from your terminal. npm can only trust a workflow for a package that already exists, so the first publish uses your own npm login:

    Terminal
    npm login
    okibi clis npm setup my-cli

    This publishes the current official version, then tells npm to trust the release workflow for each package. npm may ask you to confirm with your second factor. New packages can take a few minutes to appear on npm, so the command waits for them. If it stops waiting first, run okibi clis npm setup my-cli --skip-publish a little later to finish; it doesn't publish or ask for your second factor again.

  3. Allow rollbacks. On npmjs.com, open @acme/my-cli (the package without a platform suffix), go to Settings, and turn on Allow npm dist-tag for its trusted publisher. Rolling back moves npm's latest tag, which npm only lets the workflow do with this setting, and npm's command line can't turn it on yet.

The npm and Homebrew window shows On npm once it's done. From then on, each official release reaches npm within a few minutes, with no step from you.

Each release is seven packages: @acme/my-cli, which provides the my-cli command, plus one binary package per platform (@acme/my-cli-darwin-arm64, @acme/my-cli-linux-x64, and so on). npm installs only the one that matches the user's machine, so installing with --omit=optional leaves the CLI without its binary.

Trusted publishing adds the workflow as a way to publish; it doesn't remove the others. Your own npm login and tokens can still publish, and so can anyone who can run a workflow named release-npm.yml in your repository, which includes people with write access and the Okibi publisher app. To make the workflow the only way, open each package's Settings on npmjs.com and choose Require two-factor authentication and disallow tokens. Trusted publishing keeps working with that setting.

Homebrew

The first time it publishes, Okibi creates a public homebrew-tap repository in your GitHub account. For each release it uploads the macOS and Linux archives to that repository and updates Formula/my-cli.rb. Your users run:

Terminal
brew install acme/tap/my-cli

Share the full name. Homebrew 7 only loads formulae from taps you trust, and installing by full name trusts that one formula.

If your organization doesn't let apps create public repositories, create homebrew-tap yourself as a public repository, add it to the Okibi publisher app's repositories, and click Check again.

What happens on each release

Okibi follows the official release. Whenever it changes (a merged release pull request, a version you choose, a skill or endpoint update), npm and Homebrew get it within a few minutes. The window shows each channel's status.

  • Rollbacks. Choosing an older official version moves npm's latest back to it and rewrites the formula, so npm update and brew upgrade move users back too. A version npm or the tap already has is reused as it is. One they never got (for example, a release from before you turned publishing on) is published first.
  • When a CLI stops qualifying. If its audience stops being public, or its code moves to an Okibi-hosted repository, Okibi stops publishing. npm and Homebrew keep serving the last version they got. The window still shows each channel and lets you turn it off.
  • Repairs. When Okibi repairs a release, it releases the fix as the next patch version, which npm and Homebrew get like any other release. If a release is rebuilt under the same version, they keep the build they first got, since a published version is never replaced, and the window says so.
  • Commits to main. A commit you push to your CLI's default branch ships with the next release.

When something fails

The window says what failed and what fixes it, with a link to the workflow run. The person who turned publishing on also gets an email. Okibi retries on its own unless the fix is a setting on npmjs.com; then click Check again once it's changed.

What you seeWhat to do
npm turned down the release workflowCheck that the package's trusted publisher is your repository with release-npm.yml, or run okibi clis npm setup again.
npm still serves the newer version after a rollbackTurn on Allow npm dist-tag for the package's trusted publisher on npmjs.com (step 3 of the setup).
The package name belongs to someone elseChoose another name and save.
Approve the new Actions permissionClick Approve on GitHub. Okibi needs to read workflow runs to tell you why a release failed.
The tap has a formula Okibi didn't writeRename or remove that formula, then click Check again.

Run your own pipeline instead

You can publish with your own workflow instead. This needs the code in your own GitHub account; an Okibi-hosted repository can't hold your secrets.

Okibi pushes a vX.Y.Z tag to your repository when it releases a merged release pull request or an endpoint change. Save this workflow as .github/workflows/package-managers.yml. It waits until that version is the public official release, then downloads its archives and checks every checksum. Set the repository variable OKIBI_CLI_ID to the cli.id that okibi clis hosting status my-cli --json prints.

Example
name: Package managers
on:
  push:
    tags: ['v*']
  workflow_dispatch:
    inputs:
      version:
        description: Official version, X.Y.Z
        required: true

permissions:
  contents: read
  id-token: write

jobs:
  publish:
    runs-on: ubuntu-latest
    env:
      TAG: ${{ inputs.version || github.ref_name }}
      UPDATE_URL: https://okibi.ai/i/cli/${{ vars.OKIBI_CLI_ID }}
    steps:
      - uses: actions/checkout@v5
      - name: Wait for the official release
        id: official
        run: |
          version=${TAG#v}
          for _ in $(seq 1 6); do
            url=$(curl -fsSL -o latest.json -w '%{url_effective}' \
              "$UPDATE_URL" || true)
            current=$(jq -r .releaseVersion latest.json 2>/dev/null || true)
            if [ "$current" = "$version" ]; then
              echo "version=$version" >> "$GITHUB_OUTPUT"
              echo "base=${url%/latest.json}" >> "$GITHUB_OUTPUT"
              exit 0
            fi
            sleep 20
          done
          echo "::notice::$version isn't the official release; skipping."
      - name: Download the release archives
        if: steps.official.outputs.version != ''
        env:
          BASE: ${{ steps.official.outputs.base }}
        run: |
          mkdir -p release
          mv latest.json release/
          jq -r '.artifacts[] | "\(.url) \(.sha256)"' release/latest.json |
            while read -r path sum; do
              mkdir -p "release/$(dirname "$path")"
              curl -fsSL -o "release/$path" "$BASE/$path"
              echo "$sum  release/$path" | sha256sum -c -
            done
      # Your publishing steps go here, using release/ and
      # steps.official.outputs.version.

Skill-only updates don't push a tag. Run the workflow by hand for those, with the version from okibi clis hosting status my-cli.

Publish to npm yourself

Save this script as scripts/npm-packages.mjs. It turns a release into one package per platform, each limited to its os and cpu so npm installs only the matching one, plus a launcher that depends on all six at the exact version and runs the right binary:

Example
// Turn an Okibi release into npm packages: one per platform, plus a launcher.
// node scripts/npm-packages.mjs <release folder or update URL> <out> <package>
import { execFileSync } from 'node:child_process'
import { createHash } from 'node:crypto'
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
import { join } from 'node:path'

const [source, out, name] = process.argv.slice(2)
const remote = /^https?:/.test(source)
const base = remote
  ? (await fetch(source)).url.replace(/\/latest\.json$/, '')
  : source
const read = async (path) =>
  remote
    ? Buffer.from(await (await fetch(`${base}/${path}`)).arrayBuffer())
    : readFileSync(join(source, path))
const manifest = JSON.parse(await read('latest.json'))
const { binaryName: cli, releaseVersion: version } = manifest
const repo = process.env.GITHUB_REPOSITORY
const repository = repo && {
  type: 'git',
  url: `git+https://github.com/${repo}.git`,
}
const write = (dir, pkg) => {
  mkdirSync(join(out, dir, 'bin'), { recursive: true })
  const json = JSON.stringify({ ...pkg, version, repository }, null, 2)
  writeFileSync(join(out, dir, 'package.json'), json)
}

const platforms = {}
for (const { platform, url, sha256 } of manifest.artifacts) {
  const archive = await read(url)
  const sum = createHash('sha256').update(archive).digest('hex')
  if (sum !== sha256) throw new Error(`${url}: checksum mismatch`)
  // Okibi says windows where npm says win32.
  const [os, cpu] = platform.replace(/^windows/, 'win32').split('-')
  const dir = `${os}-${cpu}`
  platforms[`${name}-${dir}`] = version
  write(dir, { name: `${name}-${dir}`, os: [os], cpu: [cpu], files: ['bin'] })
  const bin = join(out, dir, 'bin')
  execFileSync('tar', ['-xz', '--exclude', '.okibi-*', '-C', bin], {
    input: archive,
  })
}

const LAUNCHER = `#!/usr/bin/env node
const { dirname, join } = require('node:path')
const { spawnSync } = require('node:child_process')
const { name, bin } = require('../package.json')
const cli = Object.keys(bin)[0]
const pkg = name + '-' + process.platform + '-' + process.arch
let dir
try {
  dir = dirname(require.resolve(pkg + '/package.json'))
} catch {
  console.error(cli + ': ' + pkg + ' is missing; reinstall it with optional'
    + ' dependencies')
  process.exit(1)
}
const exe = join(dir, 'bin', process.platform === 'win32' ? cli + '.exe' : cli)
const off = cli.toUpperCase().replace(/[^A-Z0-9]+/g, '_') + '_NO_AUTO_UPDATE'
const run = spawnSync(exe, process.argv.slice(2), {
  stdio: 'inherit',
  env: { ...process.env, [off]: '1' },
})
process.exit(run.status ?? 1)
`

// The launcher runs the binary for this machine. npm owns the install, so
// the binary's own updater stays off.
write('launcher', {
  name,
  bin: { [cli]: 'bin/cli.js' },
  files: ['bin'],
  optionalDependencies: platforms,
})
writeFileSync(join(out, 'launcher', 'bin', 'cli.js'), LAUNCHER, { mode: 0o755 })
console.log(`Prepared ${name}@${version}`)

Replace the comment at the end of the workflow with these steps. They publish the platform packages first and the launcher last, so the launcher never points at a binary that isn't on npm yet, and skip versions npm already has, so a rerun is safe:

Example
      - uses: actions/setup-node@v5
        if: steps.official.outputs.version != ''
        with:
          node-version: 24
          registry-url: https://registry.npmjs.org
      - name: Publish to npm
        if: steps.official.outputs.version != ''
        env:
          VERSION: ${{ steps.official.outputs.version }}
        run: |
          npm install --global npm@latest
          node scripts/npm-packages.mjs release npm @acme/my-cli
          for dir in npm/*-*/ npm/launcher/; do
            name=$(jq -r .name "$dir/package.json")
            # No token: setup-node's placeholder would fail the lookup.
            NODE_AUTH_TOKEN='' npm view "$name@$VERSION" version \
              >/dev/null 2>&1 && continue
            npm publish "$dir" --access public --tag latest
          done

npm can only trust a workflow for a package that already exists, so publish the first version from your machine, then trust the workflow for each of the seven packages:

Terminal
npm login
export GITHUB_REPOSITORY=acme/my-cli
node scripts/npm-packages.mjs https://okibi.ai/i/cli/<cli.id> npm @acme/my-cli
for dir in npm/*-*/ npm/launcher/; do
  npm publish "$dir" --access public --tag latest
done
for pkg in @acme/my-cli @acme/my-cli-{darwin,linux,win32}-{arm64,x64}; do
  npm trust github "$pkg" --repo acme/my-cli --file package-managers.yml \
    --allow-publish --yes
done

npm asks you to approve each publish and each trust in your browser; on the first approval, tick "skip two-factor authentication for the next 5 minutes" to approve the rest. To use a token instead of trusted publishing, store an npm access token as the secret NPM_TOKEN and add NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} to the publish step's env.

--tag latest is explicit because npm won't move latest to a version below its highest on its own, which is what publishing an older official version does. Rolling back to a version npm already has needs npm dist-tag add @acme/my-cli@1.2.0 latest. From the workflow, that works only with Allow npm dist-tag turned on for its trusted publisher.

Publish to Homebrew yourself

Your own tap needs a formula per release, built from the same release/ folder: a url and sha256 from latest.json for each macOS and Linux archive. Install the binaries into libexec and make bin/my-cli a wrapper that tells the CLI where its okibi-auth helper is, as Okibi's own formula does, so helpers from two CLIs never collide in Homebrew's bin:

Example
  def install
    libexec.install "my-cli"
    env = {}
    if File.exist?("okibi-auth")
      libexec.install "okibi-auth"
      env[:MY_CLI_AUTH_HELPER] = libexec/"okibi-auth"
    end
    (bin/"my-cli").write_env_script libexec/"my-cli", env
  end

The variable is the CLI's name in capitals with dashes as underscores, followed by _AUTH_HELPER.