Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions .github/workflows/drift-check.yml
Original file line number Diff line number Diff line change
@@ -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
25 changes: 25 additions & 0 deletions .github/workflows/watchdog.yml
Original file line number Diff line number Diff line change
@@ -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
140 changes: 140 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -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 <branch>` 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`.
9 changes: 9 additions & 0 deletions lefthook.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
5 changes: 4 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
48 changes: 48 additions & 0 deletions scripts/check-no-linked-sdk.mjs
Original file line number Diff line number Diff line change
@@ -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);
}
119 changes: 119 additions & 0 deletions scripts/drift-check.mjs
Original file line number Diff line number Diff line change
@@ -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);
});
Loading
Loading