diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 00000000..741ae107 --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,29 @@ +name: Lint + +on: + push: + branches: [main] + pull_request: + +jobs: + lint: + name: Lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # 7.0.0 + + - uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # 2.0.2 + with: + bun-version: 1.3.14 + + - name: Install + run: bun install --frozen-lockfile + + - name: Lint + run: bun run lint + + - name: Format + run: bun run format:check + + - name: Typecheck + run: bun run typecheck diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml index 0b40bb08..3dfc2ed3 100644 --- a/.github/workflows/release-please.yml +++ b/.github/workflows/release-please.yml @@ -5,9 +5,14 @@ on: branches: - main +# id-token: write is not used by this workflow directly, but the caller's +# token permissions cap what the called release.yml may request — its +# publish-npm job needs id-token: write for npm trusted publishing (OIDC). +# Without it here, the whole run fails at startup with a workflow-file error. permissions: contents: write pull-requests: write + id-token: write jobs: release-please: @@ -27,3 +32,6 @@ jobs: uses: ./.github/workflows/release.yml with: tag_name: ${{ needs.release-please.outputs.tag_name }} + # Called workflows receive no secrets by default; publish-homebrew needs + # secrets.SDK_BOT_PRIVATE_KEY to push the formula to workos/homebrew-tap. + secrets: inherit diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4a230f60..68114188 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -261,3 +261,79 @@ jobs: fi (cd "$dir" && npm publish --access public --provenance --tag ${{ steps.npm-tag.outputs.tag }}) done + + # Homebrew is a tertiary channel: regenerate the tap formula from the same + # release binaries and push it to workos/homebrew-tap so + # `brew install workos/tap/workos` and `brew upgrade workos` track each + # release. Stable versions only — brew users don't track prereleases. Runs + # after the GitHub release publishes so the download URLs it pins already + # resolve. Re-run just this job if it fails; the push is a no-op when the + # formula is already current. + # + # GITHUB_TOKEN cannot push to another repo, so the cross-repo write uses the + # SDK Automation Bot (a GitHub App installed on workos/homebrew-tap) — the + # same credentials the SDK repos publish with. Requires vars.SDK_BOT_APP_ID + # and secrets.SDK_BOT_PRIVATE_KEY to be available to this repo. + publish-homebrew: + name: Publish Homebrew formula + needs: [smoke, publish] + if: ${{ !contains(inputs.tag_name, '-') }} + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # 7.0.0 + with: + ref: ${{ inputs.tag_name }} + + - uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # 2.0.2 + with: + bun-version: 1.3.14 + + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # 8.0.1 + with: + path: dist + merge-multiple: true + + # Computes the sha256 for each platform binary from the downloaded + # artifacts, so the tap can never drift from the published bits. + - name: Generate the Homebrew formula + env: + TAG_NAME: ${{ inputs.tag_name }} + run: WORKOS_HOMEBREW_VERSION="${TAG_NAME#v}" bun run ./scripts/gen-homebrew-formula.ts + + # Mints an installation token scoped to the tap repo; owner/repositories + # target homebrew-tap rather than this repo, and contents:write is all a + # formula push needs. + - name: Generate a tap-scoped token + id: tap-token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # 3.2.0 + with: + app-id: ${{ vars.SDK_BOT_APP_ID }} + private-key: ${{ secrets.SDK_BOT_PRIVATE_KEY }} + owner: workos + repositories: homebrew-tap + permission-contents: write + + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # 7.0.0 + with: + repository: workos/homebrew-tap + token: ${{ steps.tap-token.outputs.token }} + path: tap + + - name: Commit and push the formula + working-directory: tap + env: + TAG_NAME: ${{ inputs.tag_name }} + run: | + cp ../dist/homebrew/workos.rb workos.rb + # --porcelain reports untracked (first release) and modified alike. + if [ -z "$(git status --porcelain -- workos.rb)" ]; then + echo "workos.rb already up to date for $TAG_NAME" + exit 0 + fi + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add workos.rb + git commit -m "workos ${TAG_NAME#v}" + git push diff --git a/.github/workflows/ci.yml b/.github/workflows/test.yml similarity index 95% rename from .github/workflows/ci.yml rename to .github/workflows/test.yml index e6d3e236..cbc07d91 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/test.yml @@ -1,4 +1,4 @@ -name: CI +name: Test on: push: @@ -6,8 +6,8 @@ on: pull_request: jobs: - ci: - name: Bun + test: + name: Test runs-on: ubuntu-latest steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # 7.0.0 @@ -19,18 +19,11 @@ jobs: - name: Install run: bun install --frozen-lockfile - - name: Lint - run: bun run lint - - - name: Format - run: bun run format:check - - - name: Typecheck - run: bun run typecheck - - name: Test run: bun run test + # `bun run test` regenerates the manifests via its pre-hook, so a stale + # committed manifest shows up as a diff here. - name: Check committed integration manifest is fresh run: git diff --exit-code src/integrations/_manifest.ts diff --git a/scripts/gen-homebrew-formula.ts b/scripts/gen-homebrew-formula.ts new file mode 100644 index 00000000..dd0c4f65 --- /dev/null +++ b/scripts/gen-homebrew-formula.ts @@ -0,0 +1,114 @@ +// Generates the Homebrew formula (dist/homebrew/workos.rb) from the already-built +// platform binaries in dist/ (release asset names). The release workflow pushes +// the result to the workos/homebrew-tap repo, so `brew install workos/tap/workos` +// tracks each GitHub release. +// +// Homebrew downloads the raw release binary (no tarball) and installs it as +// `workos`; the sha256 for each platform is computed here from the same +// artifacts the GitHub release ships, so the tap can never drift from the bits. +// +// WORKOS_HOMEBREW_VERSION version to stamp; defaults to the repo package.json +// version. Prerelease versions (containing `-`) are +// rejected — brew users track stable only. +import { createHash } from 'node:crypto'; +import { existsSync, mkdirSync, rmSync } from 'node:fs'; +import { readFile, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; + +const projectRoot = join(import.meta.dirname, '..'); +const distDir = join(projectRoot, 'dist'); +const outDir = join(distDir, 'homebrew'); + +const rootPackageJson = JSON.parse(await readFile(join(projectRoot, 'package.json'), 'utf8')) as { + version: string; + license?: string; +}; +const version = process.env.WORKOS_HOMEBREW_VERSION ?? rootPackageJson.version; +if (!/^\d+\.\d+\.\d+$/.test(version)) { + throw new Error(`Refusing to generate a Homebrew formula for non-stable version: ${version}`); +} + +// Hardcoded rather than reused from package.json: Homebrew's audit wants a desc +// with no leading article and without the formula name, unlike the npm-facing +// package description. +const DESCRIPTION = 'Install AuthKit integrations and manage WorkOS resources'; +const LICENSE = rootPackageJson.license ?? 'MIT'; +const HOMEPAGE = 'https://github.com/workos/cli'; +const DOWNLOAD_BASE = `https://github.com/workos/cli/releases/download/v${version}`; + +// Homebrew macOS + Linuxbrew (glibc) only — no musl or Windows. `on_arm`/ +// `on_intel` blocks map straight onto these four release assets. +const PLATFORMS: Array<{ os: 'macos' | 'linux'; cpu: 'arm' | 'intel'; asset: string }> = [ + { os: 'macos', cpu: 'arm', asset: 'workos-darwin-arm64' }, + { os: 'macos', cpu: 'intel', asset: 'workos-darwin-x64' }, + { os: 'linux', cpu: 'arm', asset: 'workos-linux-arm64' }, + { os: 'linux', cpu: 'intel', asset: 'workos-linux-x64' }, +]; + +const missing = PLATFORMS.filter((p) => !existsSync(join(distDir, p.asset))); +if (missing.length > 0) { + throw new Error(`Missing platform binaries in dist/: ${missing.map((p) => p.asset).join(', ')}`); +} + +async function sha256(asset: string): Promise { + return createHash('sha256') + .update(await readFile(join(distDir, asset))) + .digest('hex'); +} + +const shas = new Map(); +for (const platform of PLATFORMS) { + shas.set(platform.asset, await sha256(platform.asset)); +} + +// One `on_arm`/`on_intel` stanza; each pins the URL + sha and installs the raw +// binary (named after the URL's basename) as `workos`. +function stanza(cpu: 'arm' | 'intel', asset: string): string { + return [ + ` on_${cpu} do`, + ` url "${DOWNLOAD_BASE}/${asset}"`, + ` sha256 "${shas.get(asset)}"`, + '', + ' def install', + ` bin.install "${asset}" => "workos"`, + ' end', + ' end', + ].join('\n'); +} + +const macos = PLATFORMS.filter((p) => p.os === 'macos'); +const linux = PLATFORMS.filter((p) => p.os === 'linux'); + +const formula = `# typed: false +# frozen_string_literal: true + +# Generated by workos/cli release automation (scripts/gen-homebrew-formula.ts). +# DO NOT EDIT — changes are overwritten on the next release. +class Workos < Formula + desc "${DESCRIPTION}" + homepage "${HOMEPAGE}" + version "${version}" + license "${LICENSE}" + + on_macos do +${macos.map((p) => stanza(p.cpu, p.asset)).join('\n')} + end + + on_linux do +${linux.map((p) => stanza(p.cpu, p.asset)).join('\n')} + end + + test do + assert_match version.to_s, shell_output("#{bin}/workos --version") + end +end +`; + +rmSync(outDir, { recursive: true, force: true }); +mkdirSync(outDir, { recursive: true }); +await writeFile(join(outDir, 'workos.rb'), formula); + +console.log(`Generated Homebrew formula (v${version}) at ${join(outDir, 'workos.rb')}:`); +for (const platform of PLATFORMS) { + console.log(` ${platform.os}/${platform.cpu} ${platform.asset} ${shas.get(platform.asset)}`); +} diff --git a/src/lib/install-method.spec.ts b/src/lib/install-method.spec.ts new file mode 100644 index 00000000..2d2d6ab0 --- /dev/null +++ b/src/lib/install-method.spec.ts @@ -0,0 +1,44 @@ +import { describe, it, expect } from 'vitest'; +import { detectInstallMethod, upgradeNotice } from './install-method.js'; + +describe('detectInstallMethod', () => { + it.each([ + ['/opt/homebrew/Cellar/workos/0.18.0/bin/workos', 'homebrew'], // Apple Silicon + ['/usr/local/Cellar/workos/0.18.0/bin/workos', 'homebrew'], // Intel mac + ['/home/linuxbrew/.linuxbrew/Cellar/workos/0.18.0/bin/workos', 'homebrew'], // Linuxbrew + ['/opt/homebrew/bin/workos', 'homebrew'], // unresolved symlink on PATH + ] as const)('detects homebrew from %s', (execPath, expected) => { + expect(detectInstallMethod(execPath)).toBe(expected); + }); + + it.each([ + ['/usr/local/lib/node_modules/@workos/cli-darwin-arm64/bin/workos', 'npm'], // global prefix + ['/Users/dev/.nvm/versions/node/v20.11.0/lib/node_modules/@workos/cli-linux-x64/bin/workos', 'npm'], // nvm + ['C:\\Users\\me\\AppData\\Roaming\\npm\\node_modules\\@workos\\cli-win32-x64\\bin\\workos.exe', 'npm'], // Windows + ] as const)('detects npm from %s', (execPath, expected) => { + expect(detectInstallMethod(execPath)).toBe(expected); + }); + + it.each([ + ['/usr/local/bin/workos', 'download'], // manual copy onto PATH + ['/Users/dev/Downloads/workos-darwin-arm64', 'download'], // ran from Downloads + ['/Users/dev/.local/bin/workos', 'download'], + ['C:\\Users\\me\\bin\\workos.exe', 'download'], // Windows manual copy + ] as const)('falls back to download from %s', (execPath, expected) => { + expect(detectInstallMethod(execPath)).toBe(expected); + }); +}); + +describe('upgradeNotice', () => { + it('suggests brew for homebrew installs', () => { + expect(upgradeNotice('homebrew')).toBe('Upgrade: brew upgrade workos'); + }); + + it('suggests npm for npm installs', () => { + expect(upgradeNotice('npm')).toBe('Upgrade: npm install -g workos@latest'); + }); + + it('links to GitHub Releases for downloaded binaries', () => { + expect(upgradeNotice('download')).toBe('Download: https://github.com/workos/cli/releases/latest'); + }); +}); diff --git a/src/lib/install-method.ts b/src/lib/install-method.ts new file mode 100644 index 00000000..394f2523 --- /dev/null +++ b/src/lib/install-method.ts @@ -0,0 +1,50 @@ +// Detects how this CLI binary was installed so the "update available" notice +// can suggest the matching upgrade command instead of a one-size-fits-all +// GitHub link. The only runtime signal is process.execPath — for a Bun +// standalone binary that is the path to the compiled executable itself (not a +// runtime + script), which is exactly the file a package manager laid down. +export type InstallMethod = 'homebrew' | 'npm' | 'download'; + +const RELEASES_URL = 'https://github.com/workos/cli/releases/latest'; + +/** + * Infer the install channel from the running binary's path. + * + * - `homebrew`: Homebrew stages every formula under a versioned Cellar dir and + * symlinks it onto PATH. execPath resolves to the real Cellar path on macOS + * (`/opt/homebrew`, `/usr/local`) and Linuxbrew (`/home/linuxbrew/.linuxbrew`); + * the prefix checks also catch an unresolved `/opt/homebrew/bin` symlink. + * - `npm`: the npm launcher spawns the platform package's binary, which lives + * under `node_modules/@workos/cli-/bin/workos`. + * - `download`: a binary pulled straight from GitHub Releases onto PATH. + * + * @param execPath overridable for testing; defaults to the running binary. + */ +export function detectInstallMethod(execPath: string = process.execPath): InstallMethod { + // Normalize Windows separators first: the npm launcher's binary lives at + // …\npm\node_modules\@workos\cli-win32-x64\bin\workos.exe, which the + // POSIX-style markers below would otherwise miss. + const path = execPath.replaceAll('\\', '/'); + if (path.includes('/Cellar/') || path.includes('/opt/homebrew/') || path.includes('/.linuxbrew/')) { + return 'homebrew'; + } + if (path.includes('/node_modules/')) { + return 'npm'; + } + return 'download'; +} + +/** + * The upgrade line shown under an "Update available" notice, matched to how the + * running binary was installed. + */ +export function upgradeNotice(method: InstallMethod = detectInstallMethod()): string { + switch (method) { + case 'homebrew': + return 'Upgrade: brew upgrade workos'; + case 'npm': + return 'Upgrade: npm install -g workos@latest'; + case 'download': + return `Download: ${RELEASES_URL}`; + } +} diff --git a/src/lib/version-check.spec.ts b/src/lib/version-check.spec.ts index 98bdda95..0088622f 100644 --- a/src/lib/version-check.spec.ts +++ b/src/lib/version-check.spec.ts @@ -15,6 +15,14 @@ vi.mock('./settings.js', () => ({ getVersion: vi.fn(() => '0.3.0'), })); +// Pin the upgrade line so the assertion below doesn't depend on the ambient +// execPath of whatever runs the tests (which may itself live under Homebrew's +// Cellar or a node_modules dir). detectInstallMethod is exercised directly in +// install-method.spec.ts. +vi.mock('./install-method.js', () => ({ + upgradeNotice: vi.fn(() => 'Download: https://github.com/workos/cli/releases/latest'), +})); + const { checkForUpdates, _resetWarningState } = await import('./version-check.js'); const { yellow, dim } = await import('../utils/logging.js'); diff --git a/src/lib/version-check.ts b/src/lib/version-check.ts index 540fa2c9..bc729208 100644 --- a/src/lib/version-check.ts +++ b/src/lib/version-check.ts @@ -1,6 +1,7 @@ import { lt, valid } from 'semver'; import { yellow, dim } from '../utils/logging.js'; import { getVersion } from './settings.js'; +import { upgradeNotice } from './install-method.js'; // The web endpoint redirects to …/releases/tag/v{version} and, unlike // api.github.com, is not subject to the anonymous 60-requests/hour/IP rate @@ -42,7 +43,7 @@ export async function checkForUpdates(): Promise { if (lt(currentVersion, latestVersion)) { hasWarned = true; yellow(`Update available: ${currentVersion} → ${latestVersion}`); - dim('Download: https://github.com/workos/cli/releases/latest'); + dim(upgradeNotice()); console.log(); } } catch {