diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index af7483d..dfd6b8e 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -1,17 +1,22 @@ name: publish -run-name: "${{ format('{0} {1}', inputs.channel || (inputs.pre_release && 'rc' || 'latest'), inputs.version || inputs.bump) }}" +run-name: "${{ format('{0} {1}', inputs.channel || 'latest', inputs.version || inputs.bump || 'auto') }}" on: - # Automated releases are intentionally disabled. # Releases are manual only — trigger via workflow_dispatch. - # - # push: - # branches: - # - main + # Both "latest" and "dev" channels are dispatched by hand. There is + # no auto-publish on push. workflow_dispatch: inputs: + channel: + description: 'npm dist-tag channel — "latest" (public stable) or "dev" (internal)' + required: true + type: choice + default: latest + options: + - latest + - dev bump: - description: "Bump major, minor, or patch" + description: "Bump major/minor/patch — for latest, bumps stable; for dev, resets dev cycle" required: false type: choice options: @@ -20,25 +25,11 @@ on: - minor - major version: - description: "Override version (optional, takes precedence over bump)" + description: "Override version (X.Y.Z for latest, X.Y.Z-dev.N for dev). Wins over bump." required: false type: string - channel: - description: "npm dist-tag channel (explicit; wins over pre_release)" - required: false - type: choice - options: - - "" - - latest - - rc - - beta - pre_release: - description: "Publish to rc channel (ignored when channel is set)" - required: false - type: boolean - default: false -concurrency: ${{ github.workflow }}-${{ github.ref }}-${{ inputs.version || inputs.bump }} +concurrency: ${{ github.workflow }}-${{ github.ref }}-${{ inputs.channel }}-${{ inputs.version || inputs.bump }} # id-token:write is required for npm provenance (SLSA attestation). # This workflow must run on GitHub-hosted runners (not Blacksmith) for @@ -78,14 +69,21 @@ jobs: - name: Format check run: bun run format:check + # Fail fast on bad/missing NPM_TOKEN before any side effects + # (version.ts writes to package.json, network calls to GH, etc.) + # Surfaces auth issues in ~2s instead of mid-publish. + - name: Verify npm auth + run: npm whoami --registry=https://registry.npmjs.org/ + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + - name: Resolve version id: version run: bun script/version.ts env: + KILO_CHANNEL: ${{ inputs.channel }} KILO_BUMP: ${{ inputs.bump }} KILO_VERSION: ${{ inputs.version }} - KILO_CHANNEL: ${{ inputs.channel }} - KILO_PRE_RELEASE: ${{ inputs.pre_release && 'true' || 'false' }} GH_REPO: ${{ github.repository }} GH_TOKEN: ${{ github.token }} @@ -96,17 +94,71 @@ jobs: NPM_CONFIG_PROVENANCE: "true" KILO_CHANNEL: ${{ steps.version.outputs.channel }} - - name: Commit version bump and tag + # Smoke test: confirm the version actually appeared on the npm + # registry. `npm publish` has been known to report success while + # the registry's eventual-consistency layer drops the new version + # (rare, but real). Catching this here means we fail BEFORE + # creating tags / GH releases that would point at a non-existent + # version. Retries 3x with 5s backoff to absorb normal replication + # lag. + - name: Verify publish landed on registry env: - TAG: ${{ steps.version.outputs.tag }} VERSION: ${{ steps.version.outputs.version }} + run: | + MAX_ATTEMPTS=3 + for i in $(seq 1 $MAX_ATTEMPTS); do + PUBLISHED=$(npm view "@kilocode/openclaw-security-advisor@$VERSION" version 2>/dev/null || echo "") + if [ "$PUBLISHED" = "$VERSION" ]; then + echo "Verified: $VERSION is live on npm" + exit 0 + fi + if [ "$i" -lt "$MAX_ATTEMPTS" ]; then + echo "Attempt $i/$MAX_ATTEMPTS: registry returned '$PUBLISHED', expected '$VERSION'. Retrying in 5s..." + sleep 5 + else + echo "Attempt $i/$MAX_ATTEMPTS: registry returned '$PUBLISHED', expected '$VERSION'." + fi + done + echo "::error::npm publish reported success but $VERSION did not appear on the registry after $MAX_ATTEMPTS attempts" + exit 1 + + - name: Configure git identity run: | git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + + # Stable releases get the version bump committed back to main so + # package.json on main always reflects the latest published stable. + # Dev releases skip this: dev publishes are ephemeral and would + # otherwise pollute main with snapshot commits. + - name: Commit version bump (stable only) + if: steps.version.outputs.channel == 'latest' + env: + TAG: ${{ steps.version.outputs.tag }} + run: | git add package.json git commit -m "release: $TAG" + git push origin HEAD + + # Tag is created for both channels so future `gh release list` + # queries can find the highest dev version. For dev releases the + # tag points at an orphan commit (the package.json bump made in + # this CI runner) — pushing the tag carries the orphan commit too. + - name: Tag release + env: + TAG: ${{ steps.version.outputs.tag }} + CHANNEL: ${{ steps.version.outputs.channel }} + run: | + # For dev, we haven't committed the bump yet — do it now so the + # tag points at the bumped tree. version.ts is guaranteed to + # have modified package.json before this step runs, so this + # commit always has changes (no --allow-empty needed). + if [ "$CHANNEL" = "dev" ]; then + git add package.json + git commit -m "release: $TAG" + fi git tag "$TAG" - git push origin HEAD --tags + git push origin "$TAG" - name: Create GitHub release env: diff --git a/AGENTS.md b/AGENTS.md index a72c6d8..636422c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -49,27 +49,48 @@ the published tarball. ## Release flow Releases are triggered manually from GitHub Actions → `publish` workflow → -"Run workflow". Two common paths: +"Run workflow". Two channels exist and they map to npm dist-tags: -- **Explicit version (today's path)**: dispatch with `version=0.1.0-beta.2`, - `channel=beta`, leave bump blank. Use this for pre-release / beta / rc cuts. -- **Auto-bump stable**: dispatch with `bump=patch|minor|major`, leave version - blank, leave channel blank. CI queries the highest existing `vX.Y.Z` tag on - the repo, bumps it, publishes to the `latest` npm dist-tag. +- **`latest`** — public stable releases (`X.Y.Z`). Default for `npm install`. +- **`dev`** — internal dogfood snapshots (`X.Y.Z-dev.N`). Available via + `npm install @kilocode/openclaw-security-advisor@dev`. -`script/version.ts` handles both. See the top-of-file docstring for full env -var semantics. The workflow fails fast if the target tag already exists on -GitHub. +There is no `beta`, `rc`, `next`, or `canary`. Two channels, that's it. + +Common dispatch paths: + +- **Auto-bump stable**: `channel=latest`, `bump=patch|minor|major`. Queries + the highest existing `vX.Y.Z` tag, bumps it, publishes to `latest`. +- **Continue dev cycle**: `channel=dev`, leave bump and version blank. + Increments the dev counter on the highest existing `*-dev.N` tag. +- **Reset dev cycle**: `channel=dev`, `bump=minor` (or major/patch). Seeds + `${next-stable}-dev.1`. Use after shipping a stable release to start + the next dev cycle. +- **Explicit version**: any channel, `version=X.Y.Z` or `X.Y.Z-dev.N`. + Wins over bump. + +`script/version.ts` handles all of the above. See the top-of-file docstring +for full env var semantics. The workflow fails fast if the target tag +already exists on GitHub. + +For full step-by-step release instructions see [RELEASING.md](./RELEASING.md). ### Branch protection and the release commit -The publish workflow's final step pushes a `release: vX.Y.Z` commit + tag -directly to `main` as `github-actions[bot]`, using the default `GITHUB_TOKEN`. +The publish workflow pushes commits and/or tags to `main` as +`github-actions[bot]`, using the default `GITHUB_TOKEN`. + +- **Stable releases** (`channel=latest`) commit the `package.json` version + bump back to `main` AND push the tag. +- **Dev releases** (`channel=dev`) push only the tag (pointing at an + orphan commit). `main` history stays clean. Once branch protection / repository rulesets are enabled on `main`, the `github-actions[bot]` actor **must be added to the ruleset's bypass actors -list**, otherwise the release workflow will fail at the push step _after_ +list**, otherwise stable releases will fail at the push step _after_ `npm publish` has already succeeded — leaving npm and GitHub out of sync. +Dev releases are less affected (no commit to `main`) but still need tag +push to be allowed, which most rulesets permit by default. This is a stopgap. The long-term plan is to adopt the same `kilo-maintainer` GitHub App pattern used by the kilocode monorepo diff --git a/CHANGELOG.md b/CHANGELOG.md index 17dccc8..5e76ab3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,9 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -## [0.1.0-beta.1] - 2026-04-15 +## [0.1.0-dev.1] - 2026-04-15 -Initial beta release. +Initial dev release. ### Added @@ -21,5 +21,5 @@ Initial beta release. - Audit output validated with a Zod schema at the plugin boundary. - Public IP detection via `ifconfig.me` with IPv4/IPv6 validation. -[Unreleased]: https://github.com/Kilo-Org/openclaw-security-advisor/compare/v0.1.0-beta.1...HEAD -[0.1.0-beta.1]: https://github.com/Kilo-Org/openclaw-security-advisor/releases/tag/v0.1.0-beta.1 +[Unreleased]: https://github.com/Kilo-Org/openclaw-security-advisor/compare/v0.1.0-dev.1...HEAD +[0.1.0-dev.1]: https://github.com/Kilo-Org/openclaw-security-advisor/releases/tag/v0.1.0-dev.1 diff --git a/README.md b/README.md index c26e021..c57c5c9 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,17 @@ openclaw gateway restart That's it. On first use, the plugin will walk you through a one-time device auth flow to connect your KiloCode account. +### Channels + +The plugin ships on two npm dist-tags: + +- `latest` (default) — public stable releases (`X.Y.Z`). +- `dev` — internal dogfood snapshots (`X.Y.Z-dev.N`). Install with: + + ```bash + openclaw plugins install @kilocode/openclaw-security-advisor@dev + ``` + --- ## Usage diff --git a/RELEASING.md b/RELEASING.md index b9efa04..dc1ac19 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -4,23 +4,34 @@ Releases are cut from the `publish` workflow in GitHub Actions. There is no local release script, no automated release on push, and no changesets tool. Every release is a manual `workflow_dispatch`. +## Channels + +There are exactly two channels and they correspond to npm dist-tags: + +| Channel | npm dist-tag | Purpose | Version format | +| -------- | ------------ | -------------------------------------------------- | -------------- | +| `latest` | `latest` | Public stable releases. The default `npm install`. | `X.Y.Z` | +| `dev` | `dev` | Internal dogfood snapshots. `npm install …@dev`. | `X.Y.Z-dev.N` | + +That's the entire surface. There is no `beta`, `rc`, `next`, or `canary`. + ## Pre-flight checklist Before clicking "Run workflow", confirm: - [ ] `main` is green on all three CI workflows (`typecheck`, `test`, `format`). - [ ] `CHANGELOG.md` has the changes you're about to ship listed under `## [Unreleased]`. -- [ ] You know the exact version number you want to publish. -- [ ] The tag for that version does **not** already exist on +- [ ] You know which channel you're targeting and which inputs you'll use (see paths below). +- [ ] The tag for the resulting version does **not** already exist on https://github.com/Kilo-Org/openclaw-security-advisor/releases. - (The workflow will fail fast if it does, but check first — it's cheaper - to pick a different number than to recover from a partial publish.) + The workflow fails fast if it does, but check first — it's cheaper + to pick a different bump than to recover from a partial publish. ## Cutting a release 1. Open https://github.com/Kilo-Org/openclaw-security-advisor/actions/workflows/publish.yml 2. Click **Run workflow** (top right). -3. Fill in the inputs (see paths below). +3. Fill in the inputs — see paths below. 4. Click **Run workflow**. 5. Wait for the job to finish (typically 2–3 minutes). 6. Verify on [npm](https://www.npmjs.com/package/@kilocode/openclaw-security-advisor) @@ -28,49 +39,199 @@ Before clicking "Run workflow", confirm: 7. Verify on the [GitHub releases page](https://github.com/Kilo-Org/openclaw-security-advisor/releases) that the tag and release were created. -### Path A: Explicit version (beta, rc, custom) +### Stable releases (`channel=latest`) + +For public releases that go to `npm install @kilocode/openclaw-security-advisor`. + +**Auto-bump (the common path):** + +| Input | Value | +| --------- | ------------------------------ | +| `channel` | `latest` | +| `bump` | `patch` (or `minor` / `major`) | +| `version` | _(leave blank)_ | + +The workflow queries the highest existing `vX.Y.Z` tag, bumps it, and +publishes. Example: highest stable is `v1.2.3`, you pick `bump=patch`, +the new version is `1.2.4`. + +**Explicit version (rare):** + +| Input | Value | +| --------- | --------------- | +| `channel` | `latest` | +| `bump` | _(leave blank)_ | +| `version` | `1.2.5` | + +Use this only when you need to skip a number or seed the very first stable +release (since auto-bump from a fresh repo would resolve to `0.0.1`, which +is rarely what you want for `1.0.0`). + +### Dev snapshots (`channel=dev`) + +For internal dogfood builds. Versions look like `0.1.0-dev.1`, +`0.1.0-dev.2`, etc. They publish to the `dev` npm dist-tag, so users get +them with `npm install @kilocode/openclaw-security-advisor@dev`. + +**Continue current dev cycle (the common path):** + +| Input | Value | +| --------- | --------------- | +| `channel` | `dev` | +| `bump` | _(leave blank)_ | +| `version` | _(leave blank)_ | -Use this for pre-release versions where you want to control the exact number. +The workflow queries the highest existing `*-dev.N` tag and increments +the counter. Example: highest dev is `0.1.0-dev.5`, the new version is +`0.1.0-dev.6`. Same `0.1.0` base — only the dev counter moves. -| Input | Value | -| ------------- | ---------------------------------------------- | -| `bump` | _(leave blank)_ | -| `version` | `0.1.0-beta.1` (or whatever you want) | -| `channel` | `beta` (or `rc`, `latest`, etc.) | -| `pre_release` | _(leave unchecked — `channel` wins over this)_ | +**Start a new dev cycle (after a stable release):** -Result: publishes exactly `0.1.0-beta.1` to the `beta` npm dist-tag. +| Input | Value | +| --------- | ------------------------------ | +| `channel` | `dev` | +| `bump` | `patch` (or `minor` / `major`) | +| `version` | _(leave blank)_ | -### Path B: Auto-bump stable +The workflow takes the highest stable, applies the bump, and seeds +`dev.1`. Example: stable is `0.1.0`, you pick `bump=minor`, the new +version is `0.2.0-dev.1`. Use this when you've shipped a stable release +and want to start the next dev cycle. -Use this for normal stable releases. CI queries the highest existing -`vX.Y.Z` tag on the repo, bumps it by the requested component, publishes -to the `latest` dist-tag. +**Explicit version (one-off):** -| Input | Value | -| ------------- | ------------------------------ | -| `bump` | `patch` (or `minor` / `major`) | -| `version` | _(leave blank)_ | -| `channel` | _(leave blank)_ | -| `pre_release` | _(leave unchecked)_ | +| Input | Value | +| --------- | --------------- | +| `channel` | `dev` | +| `bump` | _(leave blank)_ | +| `version` | `0.3.0-dev.1` | -Result: if the current highest stable is `v1.2.3`, a `patch` bump -publishes `1.2.4` to `latest`. +Format must match `X.Y.Z-dev.N` exactly. Use this for the very first dev +cut (since auto-bump from a fresh repo seeds at `0.0.1-dev.1`, which is +rarely the version you actually want), or to manually skip ahead. -## After the release +## After a stable release 1. Move the `[Unreleased]` entries in `CHANGELOG.md` into a new `## [X.Y.Z] - YYYY-MM-DD` section. 2. Add a compare-link at the bottom of the file. 3. Commit these changes to `main` through a normal PR. -_(The workflow does not touch `CHANGELOG.md`. It only bumps `package.json`.)_ +The publish workflow does not touch `CHANGELOG.md`. It only bumps +`package.json`. + +For dev releases, **do not update `CHANGELOG.md`** — dev snapshots are +ephemeral and the changelog tracks user-facing stable releases only. + +## What the workflow commits back to `main` + +| Channel | Commits version bump to `main`? | Pushes git tag? | Creates GitHub release? | +| -------- | ------------------------------- | --------------- | --------------------------- | +| `latest` | Yes | Yes | Yes | +| `dev` | **No** (orphan commit + tag) | Yes | Yes (marked `--prerelease`) | + +Dev releases create a tag pointing at an orphan commit (the package.json +bump made in the CI runner). The orphan commit is reachable through the +tag but is not on any branch, so `main` history stays clean. This is +intentional — dev publishes happen frequently, and committing every +`release: v0.1.0-dev.N` back to `main` would be noise. + +## Recovery scenarios + +The workflow's steps run in this order: install → typecheck/test/format +check → **verify npm auth** → resolve version → **publish to npm** → +**verify publish landed** → commit/tag/release. Failures get progressively +more dangerous the further down the list they happen, because side effects +accumulate. Recovery procedure depends on which step failed. + +### Scenario 1: Failed before `npm publish` (no side effects) + +Includes: install, typecheck, test, format check, verify-npm-auth, and +resolve-version steps. Symptoms: `bun install` errors, type errors, test +failures, prettier complaints, `npm whoami` errors, version.ts validation +errors. + +**Recovery:** none required. Nothing was published, nothing was committed, +nothing was tagged. Just fix the underlying problem and re-dispatch the +workflow. + +The most common subtype here is **bad or missing `NPM_TOKEN`**, surfaced +by the verify-npm-auth step: + +``` +npm error code ENEEDAUTH +npm error need auth This command requires you to be logged in. +``` + +Fix: add or update the `NPM_TOKEN` secret in repo settings (see +[AGENTS.md](./AGENTS.md#release-flow) for token requirements), then +re-dispatch. Nothing else to clean up. + +### Scenario 2: Publish succeeded but registry verification failed + +Symptom: `npm publish` reported success, but the post-publish +**"Verify publish landed on registry"** step fails after 3 retries with: + +``` +::error::npm publish reported success but VERSION did not appear on the registry after 3 attempts +``` -## Recovery: push step failed after npm publish succeeded +Most likely cause: registry replication lag or a transient registry +issue. The version IS published — `npm view` just isn't seeing it from +the runner's resolved registry mirror yet. + +**Recovery:** + +1. Wait 1–2 minutes, then verify manually from your machine: + + ```bash + npm view @kilocode/openclaw-security-advisor@VERSION version + ``` + +2. If the version IS on npm now, the publish was real. Manually create + the tag and GitHub release per **Scenario 4** below — but **only the + tag/release portion**, not the npm publish portion. + +3. If the version is NOT on npm after 5 minutes, the publish actually + failed and you can re-dispatch. (This case is rare; `npm publish` + strongly tries not to lie.) + +### Scenario 3: Publish + push succeeded, GitHub release creation failed + +Symptom: npm has the new version, the git tag is pushed and visible on +GitHub, but the **"Create GitHub release"** step failed with a `gh` +error (rate limit, transient API error, missing permissions). + +This leaves a "headless" tag — version is on npm, tag exists on GitHub, +but the GitHub releases page doesn't list the new version. + +**Recovery:** create the GitHub release manually from your machine. + +```bash +# For stable releases: +gh release create vX.Y.Z \ + --repo Kilo-Org/openclaw-security-advisor \ + --title "vX.Y.Z" \ + --generate-notes + +# For dev releases (note --prerelease): +gh release create vX.Y.Z-dev.N \ + --repo Kilo-Org/openclaw-security-advisor \ + --title "vX.Y.Z-dev.N" \ + --generate-notes \ + --prerelease +``` + +The next dispatch will succeed normally because version.ts's tag-exists +precheck looks at GitHub releases — and the manual `gh release create` +above creates one. + +### Scenario 4: Publish succeeded but commit / tag push failed This is the most dangerous failure mode. Symptom: `npm publish` succeeds -(package is live on npm at the new version) but the workflow fails at the -**"Commit version bump and tag"** step with a `remote rejected` error. +(package is live on npm at the new version) and the verify-publish-landed +step passes, but the workflow fails at the **"Commit version bump (stable +only)"** or **"Tag release"** step with a `remote rejected` error. Most common cause: branch protection on `main` does not include `github-actions[bot]` in the bypass actors list. See **Branch protection** @@ -78,49 +239,83 @@ below. Recovery steps: -1. **Do not** re-run the workflow. The package is already published; a rerun - will fail at the tag-exists precheck or, worse, try to republish and fail - with `EPUBLISHCONFLICT`. -2. Create the version bump + tag locally and push them: +1. **Do not** re-run the workflow. The package is already published; a + rerun will fail at the tag-exists precheck (after another version is + resolved) or at the verify-publish-landed step. + +2. **For stable releases**, create the version bump + tag locally and push + them: + ```bash git checkout main git pull # Bump package.json manually to the version that was published. git add package.json - git commit -m "release: v0.1.0-beta.1" - git tag v0.1.0-beta.1 + git commit -m "release: v1.2.4" + git tag v1.2.4 git push origin main --tags ``` -3. Create the GitHub release manually: + +3. **For dev releases**, create just the tag pointing at an orphan commit: + + ```bash + git checkout --detach + # Bump package.json manually to the version that was published. + git add package.json + git commit -m "release: v0.1.0-dev.6" + git tag v0.1.0-dev.6 + git push origin v0.1.0-dev.6 + git checkout main # IMPORTANT: get back to a real branch before + # doing anything else, or your next git operation + # will be from detached HEAD and may be lost. + ``` + +4. Create the GitHub release manually (same as Scenario 3 above): + ```bash - gh release create v0.1.0-beta.1 \ - --title "v0.1.0-beta.1" \ - --generate-notes \ - --prerelease # omit for stable releases + gh release create v1.2.4 \ + --repo Kilo-Org/openclaw-security-advisor \ + --title "v1.2.4" \ + --generate-notes + # Add --prerelease for dev releases. ``` -4. Fix the underlying cause (branch protection bypass) before the next release. + +5. Fix the underlying cause (branch protection bypass) before the next release. ## Branch protection When branch protection / rulesets are enabled on `main`, the `github-actions[bot]` actor **must** be added to the ruleset's bypass actors -list. Without it, the publish workflow's final push step fails, triggering -the recovery procedure above. +list. Without it, the publish workflow's stable-channel commit step fails, +triggering the recovery procedure above. + +Dev-channel publishes don't push to `main` (only push the tag), so they're +less affected by branch protection on `main` itself. The tag push still +needs to be allowed — most rulesets allow tag pushes by default, but if +yours blocks them, allowlist `github-actions[bot]` for tag operations too. See [AGENTS.md](./AGENTS.md#branch-protection-and-the-release-commit) for the longer-term plan to replace the bot bypass with a dedicated GitHub App. -## First-time beta release (2026-04-15) +## First-time releases (2026-04-15) + +Today's first cut is to the `dev` channel. + +| Input | Value | +| --------- | ------------- | +| `channel` | `dev` | +| `bump` | _(blank)_ | +| `version` | `0.1.0-dev.1` | -Today's release is the first ever. There are no prior tags, so auto-bump -(Path B) would resolve to `0.0.0 → 0.0.1`, which is not what we want. -Use **Path A** with: +The explicit version is required because auto-bump from a fresh repo +(no prior tags) would resolve to `0.0.1-dev.1`, which doesn't match the +intended starting point. -| Input | Value | -| --------- | -------------- | -| `version` | `0.1.0-beta.1` | -| `channel` | `beta` | +This publishes `@kilocode/openclaw-security-advisor@0.1.0-dev.1` to the +`dev` dist-tag, creates the `v0.1.0-dev.1` tag (pointing at an orphan +commit), and creates a prerelease GitHub release. `main` history is +untouched. -This publishes `@kilocode/openclaw-security-advisor@0.1.0-beta.1` to the -`beta` dist-tag, creates the `v0.1.0-beta.1` tag + prerelease GitHub release, -and commits the version bump to `main`. +Subsequent dev cuts can leave `version` blank — the workflow auto-bumps +the dev counter (`0.1.0-dev.2`, `0.1.0-dev.3`, …) until you start a new +dev cycle with a `bump` input. diff --git a/script/publish.ts b/script/publish.ts index 2b5c22b..8de070c 100644 --- a/script/publish.ts +++ b/script/publish.ts @@ -3,10 +3,8 @@ /** * Publish script for @kilocode/openclaw-security-advisor. * - * Channel resolution (matches script/version.ts): - * KILO_CHANNEL= → explicit channel wins (e.g. "beta", "rc", "latest") - * KILO_PRE_RELEASE=true → "rc" when KILO_CHANNEL unset - * default → "latest" + * Reads the channel from KILO_CHANNEL ("latest" | "dev"); defaults to + * "latest". Channel resolution must stay in sync with script/version.ts. * * The version itself is NOT computed here — it's already been written * into package.json by script/version.ts in an earlier workflow step. @@ -22,18 +20,18 @@ import { fileURLToPath } from "url"; const dir = fileURLToPath(new URL("..", import.meta.url)); process.chdir(dir); -const channel = (() => { - if (process.env.KILO_CHANNEL) return process.env.KILO_CHANNEL; - if (process.env.KILO_PRE_RELEASE === "true") return "rc"; - return "latest"; -})(); +const channel = process.env.KILO_CHANNEL || "latest"; +if (channel !== "latest" && channel !== "dev") { + throw new Error(`KILO_CHANNEL must be "latest" or "dev", got: ${channel}`); +} + +const raw = await Bun.file("package.json").text(); +const pkg = JSON.parse(raw); console.log( - `Publishing @kilocode/openclaw-security-advisor → channel: ${channel}`, + `Publishing @kilocode/openclaw-security-advisor@${pkg.version} → channel: ${channel}`, ); -const raw = await Bun.file("package.json").text(); -const pkg = JSON.parse(raw); const original = JSON.stringify(pkg, null, 2) + "\n"; // Strip private flag so npm allows publishing. diff --git a/script/version.ts b/script/version.ts index 9bdba18..04cf76f 100644 --- a/script/version.ts +++ b/script/version.ts @@ -3,20 +3,32 @@ /** * Version resolution for @kilocode/openclaw-security-advisor. * - * Mirrors the kilocode CLI's @opencode-ai/script pattern - * (see kilocode/node_modules/@opencode-ai/script/src/index.ts for the - * reference implementation this file was adapted from). - * - * Intentionally duplicated — this repo has no cross-repo dependency on - * the kilocode monorepo. If you change version/channel semantics in - * either repo, cross-check by hand so the two stay in sync. + * Two channels only: + * - `latest` — public stable releases. Versions are plain semver: 1.2.3. + * - `dev` — internal dogfood snapshots. Versions are 1.2.3-dev.N + * where N is a monotonically increasing counter per (X.Y.Z). * * Inputs (env vars): - * KILO_CHANNEL — explicit channel (e.g. "latest", "rc", "beta"). Wins over everything. - * KILO_PRE_RELEASE — "true" → channel defaults to "rc" when KILO_CHANNEL is not set. - * KILO_BUMP — "major" | "minor" | "patch". How to bump the highest known version. - * KILO_VERSION — explicit version override (e.g. "1.2.3"). Wins over KILO_BUMP. - * GH_REPO — "owner/repo" slug. Used to query gh releases for the highest version. + * KILO_CHANNEL — "latest" | "dev". Defaults to "latest" when unset. + * KILO_BUMP — "major" | "minor" | "patch". Optional. + * KILO_VERSION — explicit version override. Wins over KILO_BUMP. + * GH_REPO — "owner/repo" slug. Used to query gh releases. + * + * Stable (`channel=latest`): + * - Explicit KILO_VERSION wins. + * - Otherwise, query the highest existing stable tag (vX.Y.Z, no + * prerelease suffix) and bump it per KILO_BUMP (default: patch). + * + * Dev (`channel=dev`): + * - Explicit KILO_VERSION wins (must look like X.Y.Z-dev.N). + * - With KILO_BUMP: resets the dev cycle. Computes the next stable + * (highest stable + bump) and seeds it as ${next}-dev.1. Use this + * when starting a new dev cycle for the next major/minor/patch. + * - Without KILO_BUMP: queries the highest existing dev tag and + * increments its dev counter. Default workflow for "publish another + * snapshot in the current cycle." + * - First-ever dev cut (no prior dev tags): seeds at + * ${highest stable + 1 patch}-dev.1. * * Outputs (written to $GITHUB_OUTPUT when available): * version, tag, channel, preview @@ -26,7 +38,13 @@ * - Throws if a release with the target tag already exists on GH_REPO. * * Local preview: - * KILO_VERSION=0.1.0-beta.1 KILO_CHANNEL=beta bun script/version.ts + * KILO_CHANNEL=dev bun script/version.ts + * KILO_CHANNEL=latest KILO_BUMP=minor bun script/version.ts + * + * NB: intentionally inlined from the kilocode CLI's @opencode-ai/script + * pattern (see kilocode/node_modules/@opencode-ai/script/src/index.ts). + * No cross-repo dependency. Cross-check by hand if either repo's + * version semantics change. */ import { $ } from "bun"; @@ -37,26 +55,34 @@ const env = { KILO_CHANNEL: process.env.KILO_CHANNEL, KILO_BUMP: process.env.KILO_BUMP, KILO_VERSION: process.env.KILO_VERSION, - KILO_PRE_RELEASE: process.env.KILO_PRE_RELEASE, GH_REPO: process.env.GH_REPO, }; const CHANNEL = (() => { - if (env.KILO_CHANNEL) return env.KILO_CHANNEL; - if (env.KILO_PRE_RELEASE === "true") return "rc"; - return "latest"; + if (!env.KILO_CHANNEL) return "latest"; + if (env.KILO_CHANNEL !== "latest" && env.KILO_CHANNEL !== "dev") { + throw new Error( + `KILO_CHANNEL must be "latest" or "dev", got: ${env.KILO_CHANNEL}`, + ); + } + return env.KILO_CHANNEL; })(); -const IS_PREVIEW = CHANNEL !== "latest"; +const IS_DEV = CHANNEL === "dev"; -type ParsedVersion = { +type StableVersion = { major: number; minor: number; patch: number; - value: string; + value: string; // "X.Y.Z" }; -function parseVersion(input: string): ParsedVersion | undefined { +type DevVersion = StableVersion & { + dev: number; + // value override: "X.Y.Z-dev.N" +}; + +function parseStable(input: string): StableVersion | undefined { const match = input.trim().match(/^v?(\d+)\.(\d+)\.(\d+)$/); if (!match) return undefined; return { @@ -67,85 +93,179 @@ function parseVersion(input: string): ParsedVersion | undefined { }; } -function compareVersion(a: ParsedVersion, b: ParsedVersion): number { +function parseDev(input: string): DevVersion | undefined { + const match = input.trim().match(/^v?(\d+)\.(\d+)\.(\d+)-dev\.(\d+)$/); + if (!match) return undefined; + return { + major: Number(match[1]), + minor: Number(match[2]), + patch: Number(match[3]), + dev: Number(match[4]), + value: `${match[1]}.${match[2]}.${match[3]}-dev.${match[4]}`, + }; +} + +function compareStable(a: StableVersion, b: StableVersion): number { 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; } -async function fetchLatestFromNpm(): Promise { - try { - const res = await fetch(`https://registry.npmjs.org/${NPM_PACKAGE}/latest`); - if (!res.ok) throw new Error(`npm registry returned ${res.status}`); - const data = (await res.json()) as { version: string }; - return data.version; - } catch { - // Package not yet published. Seed at 0.0.0 so the first patch bump - // lands at 0.0.1 (or whatever bump type was requested). - return "0.0.0"; - } +function compareDev(a: DevVersion, b: DevVersion): number { + const baseDelta = compareStable(a, b); + if (baseDelta !== 0) return baseDelta; + return a.dev - b.dev; } -async function fetchHighestKnown(): Promise { - if (!env.GH_REPO) return fetchLatestFromNpm(); +// Memoized so dev-resolution paths that need both the highest dev tag +// AND the highest stable tag (e.g. first-ever dev cut) only hit the +// GitHub API once instead of twice. +let cachedTags: string[] | undefined; +async function fetchAllTags(): Promise { + if (cachedTags !== undefined) return cachedTags; + if (!env.GH_REPO) { + cachedTags = []; + return cachedTags; + } try { const result = await $`gh release list --json tagName --limit 100 --repo ${env.GH_REPO}`.json(); const releases = result as { tagName: string }[]; - const versions = releases.flatMap((item) => { - const v = parseVersion(item.tagName); - return v ? [v] : []; - }); - const highest = versions.sort(compareVersion).at(-1); - if (highest) return highest.value; + cachedTags = releases.map((r) => r.tagName); + } catch { + // gh not installed, unauthed, or no releases yet. + cachedTags = []; + } + return cachedTags; +} + +async function fetchHighestStable(): Promise { + const tags = await fetchAllTags(); + const stables = tags.flatMap((t) => { + const v = parseStable(t); + return v ? [v] : []; + }); + return stables.sort(compareStable).at(-1); +} + +async function fetchHighestDev(): Promise { + const tags = await fetchAllTags(); + const devs = tags.flatMap((t) => { + const v = parseDev(t); + return v ? [v] : []; + }); + return devs.sort(compareDev).at(-1); +} + +async function fetchLatestStableFromNpm(): Promise { + try { + const res = await fetch(`https://registry.npmjs.org/${NPM_PACKAGE}/latest`); + if (!res.ok) throw new Error(`npm registry returned ${res.status}`); + const data = (await res.json()) as { version: string }; + const parsed = parseStable(data.version); + if (parsed) return parsed; } catch { - // gh not installed, unauthed, or no releases yet. Fall through. + // Package not yet published or registry unreachable. } - return fetchLatestFromNpm(); + // Seed at 0.0.0 so the first patch bump lands at 0.0.1. + return { major: 0, minor: 0, patch: 0, value: "0.0.0" }; } -function bumpVersion(current: string, type: string): string { - const v = parseVersion(current); - if (!v) throw new Error(`Cannot bump invalid version: ${current}`); +function bumpStable(current: StableVersion, type: string): StableVersion { const kind = type.toLowerCase(); - if (kind === "major") return `${v.major + 1}.0.0`; - if (kind === "minor") return `${v.major}.${v.minor + 1}.0`; - if (kind === "patch") return `${v.major}.${v.minor}.${v.patch + 1}`; + if (kind === "major") { + return { + major: current.major + 1, + minor: 0, + patch: 0, + value: `${current.major + 1}.0.0`, + }; + } + if (kind === "minor") { + return { + major: current.major, + minor: current.minor + 1, + patch: 0, + value: `${current.major}.${current.minor + 1}.0`, + }; + } + if (kind === "patch") { + return { + major: current.major, + minor: current.minor, + patch: current.patch + 1, + value: `${current.major}.${current.minor}.${current.patch + 1}`, + }; + } throw new Error( `Unknown bump type: ${type} (expected major | minor | patch)`, ); } -function timestampSnapshot(channel: string): string { - const ts = new Date().toISOString().slice(0, 16).replace(/[-:T]/g, ""); - return `0.0.0-${channel}-${ts}`; +function devToString(v: { + major: number; + minor: number; + patch: number; + dev: number; +}): string { + return `${v.major}.${v.minor}.${v.patch}-dev.${v.dev}`; +} + +async function resolveStableVersion(): Promise { + const current = + (await fetchHighestStable()) ?? (await fetchLatestStableFromNpm()); + const bumped = bumpStable(current, env.KILO_BUMP ?? "patch"); + return bumped.value; +} + +async function resolveDevVersion(): Promise { + if (env.KILO_BUMP) { + // Reset dev cycle: seed next ${bump}.dev.1 from highest stable. + const stable = + (await fetchHighestStable()) ?? (await fetchLatestStableFromNpm()); + const next = bumpStable(stable, env.KILO_BUMP); + return devToString({ ...next, dev: 1 }); + } + // Continue dev cycle: increment counter on highest existing dev. + const highestDev = await fetchHighestDev(); + if (highestDev) { + return devToString({ ...highestDev, dev: highestDev.dev + 1 }); + } + // First-ever dev cut: seed at next-patch-from-stable, dev.1. + const stable = + (await fetchHighestStable()) ?? (await fetchLatestStableFromNpm()); + const next = bumpStable(stable, "patch"); + return devToString({ ...next, dev: 1 }); } const VERSION: string = await (async () => { if (env.KILO_VERSION) { - // Accept either plain semver or prerelease semver (e.g. 0.1.0-beta.1). const trimmed = env.KILO_VERSION.trim().replace(/^v/, ""); - if (!/^\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?$/.test(trimmed)) { - throw new Error(`KILO_VERSION is not valid semver: ${env.KILO_VERSION}`); + // Validate: must be plain semver for latest, or X.Y.Z-dev.N for dev. + if (CHANNEL === "latest") { + if (!parseStable(trimmed)) { + throw new Error( + `KILO_VERSION must be plain semver (X.Y.Z) for channel=latest, got: ${env.KILO_VERSION}`, + ); + } + } else { + if (!parseDev(trimmed)) { + throw new Error( + `KILO_VERSION must look like X.Y.Z-dev.N for channel=dev, got: ${env.KILO_VERSION}`, + ); + } } return trimmed; } - if (IS_PREVIEW) { - if (env.KILO_BUMP) { - const current = await fetchHighestKnown(); - return bumpVersion(current, env.KILO_BUMP); - } - return timestampSnapshot(CHANNEL); - } - const current = await fetchHighestKnown(); - return bumpVersion(current, env.KILO_BUMP ?? "patch"); + return CHANNEL === "latest" + ? await resolveStableVersion() + : await resolveDevVersion(); })(); const TAG = `v${VERSION}`; // Guard against double-publishing: fail fast if a release with this tag -// already exists on the target repo. This covers both stable and preview -// channels. Skipped when GH_REPO is unset (local preview). +// already exists on the target repo. Skipped when GH_REPO is unset. if (env.GH_REPO) { const existing = await $`gh release view ${TAG} --repo ${env.GH_REPO}` .nothrow() @@ -169,7 +289,7 @@ const outputs = [ `version=${VERSION}`, `tag=${TAG}`, `channel=${CHANNEL}`, - `preview=${IS_PREVIEW}`, + `preview=${IS_DEV}`, ]; if (process.env.GITHUB_OUTPUT) { @@ -184,7 +304,7 @@ if (process.env.GITHUB_OUTPUT) { console.log( JSON.stringify( - { version: VERSION, tag: TAG, channel: CHANNEL, preview: IS_PREVIEW }, + { version: VERSION, tag: TAG, channel: CHANNEL, preview: IS_DEV }, null, 2, ), diff --git a/test/version.test.ts b/test/version.test.ts new file mode 100644 index 0000000..c8efdf9 --- /dev/null +++ b/test/version.test.ts @@ -0,0 +1,119 @@ +import { describe, test, expect, beforeAll, afterAll } from "bun:test"; +import { $ } from "bun"; +import { readFileSync, writeFileSync } from "node:fs"; + +// version.ts validation tests. +// +// All validation errors thrown by script/version.ts happen before any +// network call (gh release list / npm registry fetch), so these tests +// are fast and don't need internet. They run version.ts as a subprocess +// because the script uses top-level await + module-level side effects +// (writes package.json, writes $GITHUB_OUTPUT) that aren't friendly to +// in-process import. +// +// The validation paths exercised here all error BEFORE the package.json +// write step, so the local file is never modified. As a defensive +// measure we still snapshot + restore package.json around the suite. + +const PKG_PATH = `${process.cwd()}/package.json`; +let pkgBackup: string; + +beforeAll(() => { + pkgBackup = readFileSync(PKG_PATH, "utf-8"); +}); + +afterAll(() => { + writeFileSync(PKG_PATH, pkgBackup); +}); + +// Build a clean env that overrides any KILO_*/GH_* vars leaking from the +// developer's shell, while preserving PATH/HOME so `bun` itself can run. +function testEnv(overrides: Record): Record { + return { + PATH: process.env.PATH ?? "", + HOME: process.env.HOME ?? "", + KILO_CHANNEL: "", + KILO_BUMP: "", + KILO_VERSION: "", + GH_REPO: "", + GH_TOKEN: "", + GITHUB_OUTPUT: "", + ...overrides, + }; +} + +async function runVersion( + overrides: Record, +): Promise<{ exitCode: number; stderr: string; stdout: string }> { + const result = await $`bun script/version.ts` + .env(testEnv(overrides)) + .nothrow() + .quiet(); + return { + exitCode: result.exitCode, + stderr: result.stderr.toString(), + stdout: result.stdout.toString(), + }; +} + +describe("version.ts channel validation", () => { + test("rejects KILO_CHANNEL=beta", async () => { + const result = await runVersion({ KILO_CHANNEL: "beta" }); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain('KILO_CHANNEL must be "latest" or "dev"'); + }); + + test("rejects KILO_CHANNEL=rc", async () => { + const result = await runVersion({ KILO_CHANNEL: "rc" }); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain('KILO_CHANNEL must be "latest" or "dev"'); + }); + + test("rejects KILO_CHANNEL=anything-else", async () => { + const result = await runVersion({ KILO_CHANNEL: "experimental" }); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain('KILO_CHANNEL must be "latest" or "dev"'); + }); +}); + +describe("version.ts version-format validation", () => { + test("rejects dev-suffix version on latest channel", async () => { + const result = await runVersion({ + KILO_CHANNEL: "latest", + KILO_VERSION: "0.1.0-dev.1", + }); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain( + "must be plain semver (X.Y.Z) for channel=latest", + ); + }); + + test("rejects plain semver on dev channel", async () => { + const result = await runVersion({ + KILO_CHANNEL: "dev", + KILO_VERSION: "0.1.0", + }); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain( + "must look like X.Y.Z-dev.N for channel=dev", + ); + }); + + test("rejects garbage version string", async () => { + const result = await runVersion({ + KILO_CHANNEL: "latest", + KILO_VERSION: "not-a-version", + }); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("must be plain semver"); + }); + + test("rejects unknown bump type", async () => { + const result = await runVersion({ + KILO_CHANNEL: "latest", + KILO_BUMP: "sideways", + }); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Unknown bump type: sideways"); + }); +});