diff --git a/.github/workflows/drift-check.yml b/.github/workflows/drift-check.yml new file mode 100644 index 0000000..7ef4f3a --- /dev/null +++ b/.github/workflows/drift-check.yml @@ -0,0 +1,24 @@ +name: drift-check +on: + schedule: + - cron: '0 7 * * *' # daily at 07:00 UTC + workflow_dispatch: + +permissions: + issues: write + contents: read + +jobs: + sdk-drift: + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0 + with: + node-version: 20 + - name: Compare @rendobar/sdk dep vs npm latest + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_REPOSITORY: ${{ github.repository }} + run: node scripts/drift-check.mjs diff --git a/.github/workflows/watchdog.yml b/.github/workflows/watchdog.yml new file mode 100644 index 0000000..3ab044b --- /dev/null +++ b/.github/workflows/watchdog.yml @@ -0,0 +1,25 @@ +name: watchdog +on: + schedule: + - cron: '0 */6 * * *' # every 6h + workflow_dispatch: + +permissions: + issues: write + contents: read + pull-requests: read + +jobs: + check: + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0 + with: + node-version: 20 + - name: Detect silent release skip + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_REPOSITORY: ${{ github.repository }} + run: node scripts/watchdog.mjs diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..8574af6 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,140 @@ +# AGENTS.md — rendobar-cli + +Guide for agents and humans working on the Rendobar CLI. Companion doc for the monorepo's `AGENTS.md` at [rendobar/rendobar](https://github.com/rendobar/rendobar). + +## TL;DR + +- **Source, tests, workflows**: all in this repo +- **SDK source**: lives in the [rendobar/rendobar](https://github.com/rendobar/rendobar) monorepo under `packages/sdk/`. Consumed here as `@rendobar/sdk@^1.0.0` from npm. +- **Release**: conventional commits → release-please → tag → `cli-binaries.yml` builds 5 platform binaries with attestations → GitHub Release +- **No manual tags, no manual version bumps** + +## Dev loop + +```bash +git clone https://github.com/rendobar/cli.git +cd cli && pnpm install +pnpm test # 48 tests via bun test +pnpm typecheck # tsc --noEmit +pnpm dev -- --version # run from source +pnpm build # compile standalone rb / rb.exe +./rb --version +``` + +## Working on SDK + CLI simultaneously + +When you need unreleased SDK changes in CLI: + +```bash +# Prereq: clone the monorepo as a sibling (or set RENDOBAR_MONOREPO env var) +# ../rendobar/packages/sdk +pnpm dev:sdk-local # builds monorepo SDK + pnpm-links it into this repo +pnpm test # tests now run against your local SDK +# ...iterate... +pnpm dev:sdk-npm # restores @rendobar/sdk from package.json (npm version) +``` + +**A pre-commit hook blocks commits while the SDK is linked.** It's automatic — if you forget to unlink, `git commit` fails with an instruction to run `pnpm dev:sdk-npm`. + +The sibling path is `../rendobar/packages/sdk` by default. Override via: + +```bash +RENDOBAR_MONOREPO=/custom/path/to/monorepo pnpm dev:sdk-local +``` + +## Conventional commits + +Every commit on main MUST follow [Conventional Commits](https://www.conventionalcommits.org/). release-please parses them to compute version bumps. Non-conventional commits are silently skipped — the watchdog (`.github/workflows/watchdog.yml`) will open an issue within 6h if this happens. + +This repo is single-product: **no commit scope needed**. + +| Commit | Bump | +|---|---| +| `fix: ...` | patch (`1.0.0` → `1.0.1`) | +| `feat: ...` | minor (`1.0.0` → `1.1.0`) | +| `feat!: ...` or `BREAKING CHANGE:` footer | major (`1.0.0` → `2.0.0`) | +| `chore:`, `docs:`, `test:`, `ci:`, `refactor:` | no bump, no CHANGELOG | + +**Force a specific version:** add `Release-As: 1.2.3` footer to a `chore:` commit. + +Examples: + +``` +feat: add rb batch command for parallel submissions +fix: handle malformed checksums.txt in rb update +feat!: rename --output flag to --out + +BREAKING CHANGE: --output is now --out +``` + +## Release flow (fully automated) + +``` +commit `feat: X` on main + ↓ release-please.yml fires +release-please opens PR: "chore: release main" with bumped version + ↓ auto-merge (when branch protection required checks pass) +tag v1.X.0 pushed by release-please + ↓ cli-binaries.yml fires on the v* tag +5 platform builds + attestations + release + smoke tests + ↓ +release live at github.com/rendobar/cli/releases + ↓ users on older versions +rb update → self-replaces with checksum verification + rollback +``` + +**You never touch tags.** Pushing a tag manually is a footgun — release-please owns them. + +## Guardrails already in place + +| Guardrail | Where | What it catches | +|---|---|---| +| Branch protection on `main` | GitHub settings (public repo, free) | Direct pushes, required `test` + `lint` checks before merge, linear history | +| `commitlint` on every PR title | `.github/workflows/pr-title.yml` | Non-conventional PR titles | +| `lefthook commit-msg` hook | `lefthook.yml` | Non-conventional local commits | +| `lefthook pre-commit` guard | `lefthook.yml` | Committing while SDK is pnpm-linked | +| `pnpm typecheck` + `bun test` | `.github/workflows/test.yml` | Broken code in PRs | +| Watchdog cron (every 6h) | `.github/workflows/watchdog.yml` | Silent release skips | +| SDK drift check cron (daily) | `.github/workflows/drift-check.yml` | Stale `@rendobar/sdk` dep vs npm latest | +| Build provenance attestations | `.github/workflows/cli-binaries.yml` | Verify binary was built by this workflow on this commit | +| Cross-platform smoke test | `.github/workflows/cli-binaries.yml` | Binary runs on macOS/Linux/Windows | + +## Verifying a released binary + +```bash +# Download any release asset +curl -fsSL -o rb.tar.gz https://github.com/rendobar/cli/releases/download/v1.0.0/rb-linux-x64.tar.gz + +# Verify build provenance +gh attestation verify rb.tar.gz --repo rendobar/cli +``` + +Attestations prove the binary was built by this exact workflow on this exact commit, signed by GitHub's OIDC issuer. + +## For agents + +When asked to add a feature or fix a bug: + +1. Check `pnpm test && pnpm typecheck` is green before you touch anything +2. Branch off `main`: `git checkout -b feat/short-name` +3. Make changes + write tests +4. `pnpm test && pnpm typecheck` locally +5. Commit with a conventional message: `feat: ...` or `fix: ...` +6. `git push -u origin ` and `gh pr create --title "feat: ..."` +7. Wait for CI green, merge +8. Walk away — release-please handles the rest + +**DO NOT**: +- Push directly to main (branch protection will reject, but don't try) +- Push tags manually (release-please owns tags) +- Commit while `@rendobar/sdk` is pnpm-linked (pre-commit hook rejects) +- Edit `CHANGELOG.md` or bump `version` in `package.json` (release-please owns both) +- Use `git commit --no-verify` (blocks hooks; investigate the hook failure instead) +- Add a commit scope like `feat(cli): ...` (single-product repo, bare `feat:` is correct) + +## Gotchas + +- **Bun version**: pinned to `1.3.12` in all workflows. Don't bump without testing all 5 platforms. +- **macOS sha256**: use `shasum -a 256 -c`, never `sha256sum -c` (non-existent on macOS). +- **Tag-triggered workflows**: GitHub won't fire workflows on tags pushed by `GITHUB_TOKEN`. release-please handles this internally. If `cli-binaries.yml` doesn't fire after a release-please merge, delete the release+tag, push tag from local (user PAT triggers workflows). +- **Attestations permission**: requires `attestations: write` in workflow permissions, separate from `id-token: write`. diff --git a/lefthook.yml b/lefthook.yml index de32695..d7592e2 100644 --- a/lefthook.yml +++ b/lefthook.yml @@ -4,3 +4,12 @@ commit-msg: commands: commitlint: run: npx --no-install commitlint --edit {1} + +pre-commit: + commands: + no-linked-sdk: + run: node scripts/check-no-linked-sdk.mjs + fail_text: | + @rendobar/sdk is pnpm-linked to a local checkout. Run `pnpm dev:sdk-npm` to restore + the npm version before committing. Bypass only if you know what you're doing: + lefthook run pre-commit --force diff --git a/package.json b/package.json index 54fff78..b58c988 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,10 @@ "generate-version": "node scripts/generate-version.mjs", "build": "pnpm generate-version && bun build --compile --minify src/main.ts --outfile rb", "test": "pnpm generate-version && bun test", - "typecheck": "pnpm generate-version && tsc --noEmit" + "typecheck": "pnpm generate-version && tsc --noEmit", + "dev:sdk-local": "node scripts/link-local-sdk.mjs", + "dev:sdk-npm": "node scripts/unlink-local-sdk.mjs", + "check:no-linked-sdk": "node scripts/check-no-linked-sdk.mjs" }, "dependencies": { "@rendobar/sdk": "^1.0.0", diff --git a/scripts/check-no-linked-sdk.mjs b/scripts/check-no-linked-sdk.mjs new file mode 100644 index 0000000..bda0e18 --- /dev/null +++ b/scripts/check-no-linked-sdk.mjs @@ -0,0 +1,48 @@ +#!/usr/bin/env node +// scripts/check-no-linked-sdk.mjs +// Pre-commit guard: rejects commits while @rendobar/sdk is pnpm-linked to a +// local dev checkout (via `pnpm link --global`). +// +// pnpm ALWAYS symlinks node_modules/@rendobar/sdk to node_modules/.pnpm/... +// for normal installs, so "is a symlink" alone isn't enough. We check if the +// symlink target contains "/.pnpm/" or "\\.pnpm\\" — if yes, it's a normal +// pnpm store install; if no, it's a local dev link and we block the commit. +import { lstatSync, readlinkSync, existsSync } from "node:fs"; +import { resolve } from "node:path"; + +const SDK_PATH = resolve("node_modules", "@rendobar", "sdk"); + +if (!existsSync(SDK_PATH)) { + process.exit(0); +} + +try { + const stat = lstatSync(SDK_PATH); + if (!stat.isSymbolicLink()) { + // Real directory — probably a manual copy or non-pnpm install + process.exit(0); + } + + const target = readlinkSync(SDK_PATH); + const normalized = target.replace(/\\/g, "/"); + + // Normal pnpm store install: target lives under node_modules/.pnpm/ + if (normalized.includes("/.pnpm/")) { + process.exit(0); + } + + // Anything else = dev link to a sibling checkout + console.error(""); + console.error(" ✗ @rendobar/sdk is pnpm-linked to a local checkout."); + console.error(` Link target: ${target}`); + console.error(""); + console.error(" Commit blocked. Local-linked SDK means your CLI works"); + console.error(" only against unreleased SDK code and will break in CI."); + console.error(""); + console.error(" Fix: run `pnpm dev:sdk-npm` to restore the npm version, then commit."); + console.error(""); + process.exit(1); +} catch (err) { + console.error(`[check-no-linked-sdk] error: ${err.message}`); + process.exit(0); +} diff --git a/scripts/drift-check.mjs b/scripts/drift-check.mjs new file mode 100644 index 0000000..e7ef2ec --- /dev/null +++ b/scripts/drift-check.mjs @@ -0,0 +1,119 @@ +#!/usr/bin/env node +// scripts/drift-check.mjs +// Checks if the CLI's @rendobar/sdk dep is behind the latest version on npm. +// Opens a GH issue if drifted by more than a patch version. +// Runs via .github/workflows/drift-check.yml. +import { execSync } from "node:child_process"; +import { readFileSync } from "node:fs"; + +const REPO = process.env.GITHUB_REPOSITORY ?? "rendobar/cli"; +const PACKAGE = "@rendobar/sdk"; + +function gh(cmd) { + return execSync(`gh ${cmd}`, { encoding: "utf-8" }).trim(); +} + +function log(msg) { + console.log(`[drift-check] ${msg}`); +} + +function getCurrentDep() { + const pkg = JSON.parse(readFileSync("package.json", "utf-8")); + const dep = pkg.dependencies?.[PACKAGE] ?? pkg.devDependencies?.[PACKAGE]; + if (!dep) throw new Error(`${PACKAGE} not found in package.json`); + return dep.replace(/^[\^~]/, ""); +} + +async function getNpmLatest() { + const res = await fetch(`https://registry.npmjs.org/${PACKAGE}/latest`); + if (!res.ok) throw new Error(`npm registry returned ${res.status}`); + const body = await res.json(); + return body.version; +} + +function parseSemver(v) { + const m = /^(\d+)\.(\d+)\.(\d+)/.exec(v); + if (!m) return null; + return { major: +m[1], minor: +m[2], patch: +m[3] }; +} + +function compareSemver(a, b) { + if (a.major !== b.major) return a.major - b.major; + if (a.minor !== b.minor) return a.minor - b.minor; + return a.patch - b.patch; +} + +function hasExistingDriftIssue(latest) { + try { + const issues = JSON.parse( + gh( + `issue list --search "drift-check: ${PACKAGE} behind ${latest} in:title" --state open --json number --repo ${REPO}`, + ), + ); + return issues.length > 0; + } catch { + return false; + } +} + +function openIssue(current, latest) { + const body = `Drift check detected that \`${PACKAGE}\` dependency is behind the latest published version. + +| Location | Version | +|---|---| +| \`package.json\` | \`${current}\` | +| npm latest | \`${latest}\` | + +### Fix + +Run: + +\`\`\`bash +pnpm up ${PACKAGE}@${latest} +git checkout -b chore/bump-sdk-${latest} +git add package.json pnpm-lock.yaml +git commit -m "chore(deps): bump ${PACKAGE} to ${latest}" +git push -u origin chore/bump-sdk-${latest} +gh pr create +\`\`\` + +This issue was opened automatically by \`.github/workflows/drift-check.yml\`. It will be closed automatically when the dependency is updated.`; + + gh( + `issue create --repo ${REPO} --title "drift-check: ${PACKAGE} behind ${latest}" --body "${body.replace(/"/g, '\\"').replace(/\n/g, "\\n")}" --label dependencies --label automated`, + ); + log(`opened drift issue for ${PACKAGE}: ${current} → ${latest}`); +} + +async function main() { + const current = getCurrentDep(); + log(`current ${PACKAGE} dep: ${current}`); + + const latest = await getNpmLatest(); + log(`npm latest ${PACKAGE}: ${latest}`); + + const currentParsed = parseSemver(current); + const latestParsed = parseSemver(latest); + if (!currentParsed || !latestParsed) { + log("unable to parse semver — skipping"); + return; + } + + const diff = compareSemver(latestParsed, currentParsed); + if (diff <= 0) { + log("dep is up-to-date"); + return; + } + + if (hasExistingDriftIssue(latest)) { + log(`drift issue for ${latest} already open`); + return; + } + + openIssue(current, latest); +} + +main().catch((err) => { + console.error(`[drift-check] fatal: ${err.message}`); + process.exit(1); +}); diff --git a/scripts/link-local-sdk.mjs b/scripts/link-local-sdk.mjs new file mode 100644 index 0000000..dc601ae --- /dev/null +++ b/scripts/link-local-sdk.mjs @@ -0,0 +1,48 @@ +#!/usr/bin/env node +// scripts/link-local-sdk.mjs +// Links a sibling checkout of @rendobar/sdk into rendobar-cli for local +// cross-repo development. Runs `pnpm link --global` from the SDK dir, then +// `pnpm link --global @rendobar/sdk` from cli. +// +// Usage: +// pnpm dev:sdk-local # uses ../rendobar/packages/sdk +// RENDOBAR_MONOREPO=/path pnpm dev:sdk-local # custom path +// +// Unlink with `pnpm dev:sdk-npm`. +import { execSync } from "node:child_process"; +import { existsSync } from "node:fs"; +import { resolve } from "node:path"; + +const MONOREPO = process.env.RENDOBAR_MONOREPO ?? resolve("..", "rendobar"); +const SDK_DIR = resolve(MONOREPO, "packages", "sdk"); + +function run(cmd, cwd) { + console.log(`[link-sdk] ${cwd ? `(${cwd}) ` : ""}$ ${cmd}`); + execSync(cmd, { stdio: "inherit", cwd }); +} + +function main() { + if (!existsSync(SDK_DIR)) { + console.error(`[link-sdk] SDK not found at ${SDK_DIR}`); + console.error(`[link-sdk] Clone the monorepo as a sibling, or set RENDOBAR_MONOREPO=/absolute/path`); + process.exit(1); + } + + console.log(`[link-sdk] using SDK at ${SDK_DIR}`); + + // Build SDK first — pnpm link picks up the dist/ folder + run("pnpm --filter @rendobar/sdk build", MONOREPO); + + // Register globally from SDK dir + run("pnpm link --global", SDK_DIR); + + // Link from cli dir + run("pnpm link --global @rendobar/sdk"); + + console.log(""); + console.log("[link-sdk] ✓ @rendobar/sdk is now linked from", SDK_DIR); + console.log("[link-sdk] Run `pnpm dev:sdk-npm` before committing."); + console.log("[link-sdk] The pre-commit hook will block commits while the SDK is linked."); +} + +main(); diff --git a/scripts/unlink-local-sdk.mjs b/scripts/unlink-local-sdk.mjs new file mode 100644 index 0000000..29ae992 --- /dev/null +++ b/scripts/unlink-local-sdk.mjs @@ -0,0 +1,24 @@ +#!/usr/bin/env node +// scripts/unlink-local-sdk.mjs +// Reverses scripts/link-local-sdk.mjs — unlinks the global pnpm link and +// reinstalls the published @rendobar/sdk version from package.json. +import { execSync } from "node:child_process"; + +function run(cmd) { + console.log(`[unlink-sdk] $ ${cmd}`); + try { + execSync(cmd, { stdio: "inherit" }); + } catch (err) { + // unlink can fail if nothing is linked — that's fine + console.log(`[unlink-sdk] (non-fatal) ${err.message.split("\n")[0]}`); + } +} + +function main() { + run("pnpm unlink --global @rendobar/sdk"); + run("pnpm install"); + console.log(""); + console.log("[unlink-sdk] ✓ @rendobar/sdk restored to npm version from package.json"); +} + +main(); diff --git a/scripts/watchdog.mjs b/scripts/watchdog.mjs new file mode 100644 index 0000000..80a4eba --- /dev/null +++ b/scripts/watchdog.mjs @@ -0,0 +1,164 @@ +#!/usr/bin/env node +// scripts/watchdog.mjs +// Detects silent release skips: commits on main since last v* tag with +// conventional feat/fix/perf types but no open release-please PR. +// Opens a GH issue if detected. Runs via .github/workflows/watchdog.yml. +import { execSync } from "node:child_process"; + +const CONVENTIONAL_BUMP = /^(feat|fix|perf|revert)(\([a-z0-9-]+\))?!?:/; +const REPO = process.env.GITHUB_REPOSITORY ?? "rendobar/cli"; +const GRACE_MS = 2 * 60 * 60 * 1000; // 2h — don't nag within the grace window + +function gh(cmd) { + return execSync(`gh ${cmd}`, { encoding: "utf-8", maxBuffer: 10 * 1024 * 1024 }).trim(); +} + +function log(msg) { + console.log(`[watchdog] ${msg}`); +} + +function getLastTag() { + try { + const tags = JSON.parse(gh(`api "repos/${REPO}/tags?per_page=100"`)); + const vTags = tags.filter((t) => /^v\d+\.\d+\.\d+$/.test(t.name)); + return vTags[0]?.name ?? null; + } catch (err) { + log(`failed to fetch tags: ${err.message}`); + return null; + } +} + +function getTagDate(tag) { + try { + const commit = JSON.parse(gh(`api "repos/${REPO}/commits/${encodeURIComponent(tag)}"`)); + return new Date(commit.commit.committer.date); + } catch (err) { + log(`failed to fetch tag date: ${err.message}`); + return null; + } +} + +function getCommitsSince(since) { + try { + const isoSince = since.toISOString(); + const commits = JSON.parse( + gh(`api "repos/${REPO}/commits?sha=main&since=${isoSince}&per_page=100"`), + ); + return commits; + } catch (err) { + log(`failed to fetch commits: ${err.message}`); + return []; + } +} + +function getOpenReleasePR() { + try { + const prs = JSON.parse( + gh( + `pr list --search "release-please in:title" --state open --json number,title,createdAt,headRefName --repo ${REPO}`, + ), + ); + return prs; + } catch (err) { + log(`failed to fetch release PRs: ${err.message}`); + return []; + } +} + +function hasExistingWatchdogIssue(tag) { + try { + const issues = JSON.parse( + gh( + `issue list --search "watchdog: silent release skip after ${tag} in:title" --state open --json number --repo ${REPO}`, + ), + ); + return issues.length > 0; + } catch { + return false; + } +} + +function openIssue(tag, skippedCommits) { + const commitLines = skippedCommits + .slice(0, 10) + .map((c) => `- \`${c.sha.slice(0, 7)}\` ${c.commit.message.split("\n")[0]}`) + .join("\n"); + const body = `Watchdog detected ${skippedCommits.length} conventional commit(s) on \`main\` since the last release tag \`${tag}\`, but no open release-please PR. + +This usually means: +1. release-please failed to run after a merge to main +2. The commits were pushed directly to main without triggering workflows +3. A prior release PR was closed without merging + +### Skipped commits + +${commitLines} + +### Fix + +- Re-run the release-please workflow manually: \`gh workflow run release-please --repo ${REPO}\` +- Or push an empty commit to main to re-trigger it + +This issue was opened automatically by \`.github/workflows/watchdog.yml\`. Close it once the release PR exists.`; + + try { + gh( + `issue create --repo ${REPO} --title "watchdog: silent release skip after ${tag}" --body "${body.replace(/"/g, '\\"').replace(/\n/g, "\\n")}" --label automated`, + ); + log(`opened watchdog issue for tag ${tag}`); + } catch (err) { + log(`failed to open issue: ${err.message}`); + } +} + +async function main() { + const lastTag = getLastTag(); + if (!lastTag) { + log("no v* tags found — skipping (first release not yet shipped)"); + return; + } + log(`last release tag: ${lastTag}`); + + const tagDate = getTagDate(lastTag); + if (!tagDate) { + log("could not resolve tag date — skipping"); + return; + } + + const commits = getCommitsSince(tagDate); + const skipped = commits.filter((c) => { + const subject = c.commit.message.split("\n")[0]; + return CONVENTIONAL_BUMP.test(subject); + }); + + if (skipped.length === 0) { + log("no conventional commits since last tag — healthy"); + return; + } + + const newestSkipped = new Date(skipped[0].commit.committer.date); + const ageMs = Date.now() - newestSkipped.getTime(); + if (ageMs < GRACE_MS) { + log(`newest skipped commit is ${Math.round(ageMs / 60000)}min old, under grace window — healthy`); + return; + } + + const releasePRs = getOpenReleasePR(); + if (releasePRs.length > 0) { + log(`found ${releasePRs.length} open release PR(s) — healthy`); + return; + } + + if (hasExistingWatchdogIssue(lastTag)) { + log(`watchdog issue for ${lastTag} already exists — skipping`); + return; + } + + log(`ALERT: ${skipped.length} conventional commits after ${lastTag} with no open release PR`); + openIssue(lastTag, skipped); +} + +main().catch((err) => { + console.error(`[watchdog] fatal: ${err.message}`); + process.exit(1); +});