Skip to content

Commit 8b43539

Browse files
chenliuyunchenliuyun
andauthored
Ship v3.2.1 with hardened release pipeline (#35)
* fix(capabilities): export COMMAND_META, add rules explain entry, add exhaustive coverage guard tests * test(devices): assert exit code 1 for API error 190, add happy-path exit code guard * docs: update test count to 1889 * fix(policy): relax alias deviceId pattern to accept hex MAC and lowercase IDs * docs: update test count to 1895 * feat(agent-bootstrap): add --sections flag to project top-level payload keys * docs: update test count to 1898 * perf: switch build:prod to esbuild bundler, inline pure-JS deps into single file * docs: update test count to 1900 * refactor(capabilities,schema): add schema export --capabilities, replace inline catalog with pointer note * test(daemon): add stop, status, reload subcommand coverage * test(rules): add conflicts, doctor, summary, last-fired coverage * test: add webhook token, suggest, health-check, scenes validate/simulate, status-sync, upgrade-check coverage * test: address code review findings — timer cleanup, bundle guard, status-sync start/run, test count - Add afterEach vi.useRealTimers() to daemon stop/reload describe blocks to prevent fake timer leak - Fix bundle-size test to skip size assertion when tsc output is present (detects esbuild bundle by absence of relative imports) - Add status-sync start (--json + human) and run (exit 0 and exit 1) test coverage - Update README test count to 1959 * chore: bump version to 3.2.0 * fix: remove duplicate shebang — src/index.ts had shebang AND bundle.mjs banner both added it * chore: bump version to 3.2.1 * test: add esbuild bundle validation tests (shebang, syntax, size, version smoke) * fix(bundle): resolve CJS require shim and duplicate shebang in esbuild output - Remove shebang from src/index.ts; bundle.mjs banner is the sole source - Add createRequire-based require shim in banner so bundled CJS packages (yaml, commander) can call bare require('process') on Node 20/22 - Add scripts/cjs-shim.mjs as esbuild inject target for require polyfill - Fix agent-bootstrap and upgrade-check to import version from src/version.ts instead of require('../../package.json') which breaks when bundled to a non-root dist/ location - Rewrite bundle-size test: build to dist/bundle-test.js (same level as dist/index.js so ../package.json resolves correctly), add shebang-count, node --check, --version semver, and size < 15 MB assertions * Harden npm package publish and smoke verification * chore(ci): align pack-install smoke with publish artifact, document release pipeline Add a parallel pack-install-smoke-tsc CI job that runs `npm run build` (tsc) + smoke:pack-install, mirroring exactly what publish.yml does before npm publish. Previously only the esbuild bundle was smoke-tested on PRs while publish shipped the tsc output, so a tsc-specific packaging regression would only surface after the release tag was already pushed. Add docs/release-pipeline.md with the tsc vs esbuild split, the full gate sequence, and the invariants that must hold for future pipeline changes. Link it from README and the CHANGELOG 3.2.1 entry. * fix(ci): align publish.yml with prepublishOnly — single esbuild publish source publish.yml was running `npm run build` (tsc) + smoke:pack-install, but `npm publish` triggers prepublishOnly, which does `clean && build:prod` unconditionally. So the tarball that smoke validated was thrown away and replaced with the esbuild bundle right before upload — smoke was verifying a different artifact than what shipped. Fix by making esbuild the single publish source: - publish.yml step 2: `npm run build` -> `npm run build:prod`, so the artifact validated by smoke:pack-install is byte-identical to what prepublishOnly produces and what npm publish uploads. - ci.yml: remove the `pack-install-smoke-tsc` job added in the previous commit — it was validating the tsc output that never ships. - docs/release-pipeline.md: rewrite to reflect the single-publish-source model; add explicit invariant that publish.yml and prepublishOnly must use the same builder. - CHANGELOG: update the 3.2.1 release-pipeline entry. * ci(bundle-smoke): make blocking + matrix across Node 18/20/22 Now that the esbuild bundle is the single publish source, bundle-smoke must catch bundle regressions on every supported Node version before a PR can merge. Previously it was advisory (continue-on-error: true) and single-node (20.x). Changes: - Remove continue-on-error. The stale "Node 22 CJS interop issues" comment dates from before 7bbbc44 fixed the CJS require shim; the bundle now runs cleanly on Node 18/20/22 (verified locally on 22.21). - Add strategy.matrix.node-version = [18.x, 20.x, 22.x] with fail-fast: false, so any Node version that cannot run the bundle is surfaced independently. - Update docs/release-pipeline.md: remove the "Known gaps" section, add invariant #5 making the matrix + blocking status explicit. - CHANGELOG entry added. * test(status-sync): replace vi.spyOn(process.kill) with direct assignment On some Linux CI runners, vi.spyOn(process, 'kill') failed to reliably intercept process.kill(pid, 0), so isProcessRunning hit the real kill syscall against a PID that happened to exist in the container, got back EPERM, and reported the stale process as still running. Replace the spy with a direct property assignment and restore the original kill in afterAll so the interception is deterministic across Node 18/20/22. * refactor(build): collapse build and build:prod into a single release pipeline npm run build is now the only path that produces the published artifact. It drives a 5-stage orchestrator in scripts/build.mjs: 1. clean remove dist/ 2. typecheck tsc --noEmit 3. bundle scripts/bundle.mjs (esbuild) 4. copy-assets scripts/copy-assets.mjs (policy assets only) 5. ensure-binary scripts/ensure-binary.mjs (shebang + chmod 0755 guard) prepublishOnly, verify:pre-commit, verify:pre-push, publish.yml, and the bundle-smoke / pack-install-smoke CI jobs all call npm run build by name; no job re-implements any step. scripts/copy-assets.mjs no longer injects the shebang or chmods the entry. The new scripts/ensure-binary.mjs is a regression guard: it asserts the shebang is present and fails loudly (pointing at scripts/bundle.mjs) if the esbuild banner ever drops it, rather than silently repairing the output the way copy-assets used to. Dropped scripts: build:prod, clean (folded into build.mjs). Added script: typecheck (tsc --noEmit). * chore: bump version to 3.2.2 --------- Co-authored-by: chenliuyun <chenliuyun@onero.com>
1 parent 8c0169f commit 8b43539

37 files changed

Lines changed: 2551 additions & 156 deletions

.githooks/pre-commit

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
#!/usr/bin/env sh
2+
set -eu
3+
4+
REPO_ROOT="$(git rev-parse --show-toplevel)"
5+
cd "$REPO_ROOT"
6+
7+
echo "[pre-commit] packaging sanity checks"
8+
npm run verify:pre-commit

.githooks/pre-push

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
#!/usr/bin/env sh
2+
set -eu
3+
4+
REPO_ROOT="$(git rev-parse --show-toplevel)"
5+
cd "$REPO_ROOT"
6+
7+
echo "[pre-push] tarball install smoke"
8+
npm run verify:pre-push

.github/workflows/ci.yml

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,44 @@ jobs:
4444
fi
4545
- run: npm test
4646

47+
bundle-smoke:
48+
name: esbuild bundle smoke test (Node ${{ matrix.node-version }})
49+
runs-on: ubuntu-latest
50+
needs: test
51+
strategy:
52+
fail-fast: false
53+
matrix:
54+
node-version: [18.x, 20.x, 22.x]
55+
steps:
56+
- uses: actions/checkout@v4
57+
- uses: actions/setup-node@v4
58+
with:
59+
node-version: ${{ matrix.node-version }}
60+
cache: npm
61+
- run: npm ci
62+
- run: npm run build
63+
- name: Shebang count must be exactly 1
64+
run: |
65+
COUNT=$(grep -c "#!/usr/bin/env node" dist/index.js)
66+
if [ "$COUNT" -ne 1 ]; then
67+
echo "FAIL: Expected 1 shebang, found $COUNT"
68+
exit 1
69+
fi
70+
echo "OK: shebang count = $COUNT"
71+
- name: Node.js syntax check
72+
run: node --check dist/index.js
73+
- name: --version smoke test (exits 0, outputs correct semver)
74+
run: |
75+
PKG=$(node -p "require('./package.json').version")
76+
CLI=$(node dist/index.js --version)
77+
echo "package.json=$PKG bundle=$CLI"
78+
if [ "$PKG" != "$CLI" ]; then
79+
echo "FAIL: version mismatch"
80+
exit 1
81+
fi
82+
- name: Bundle size check
83+
run: npm test -- tests/build/
84+
4785
offline-smoke:
4886
name: Offline size budgets
4987
runs-on: ubuntu-latest
@@ -89,6 +127,21 @@ jobs:
89127
exit 1
90128
fi
91129
130+
pack-install-smoke:
131+
name: Packed install smoke (esbuild — matches publish)
132+
runs-on: ubuntu-latest
133+
needs: test
134+
steps:
135+
- uses: actions/checkout@v4
136+
- uses: actions/setup-node@v4
137+
with:
138+
node-version: 20.x
139+
cache: npm
140+
- run: npm ci
141+
- run: npm run build
142+
- name: npm pack -> npm install tarball -> switchbot --version
143+
run: npm run smoke:pack-install
144+
92145
policy-schema-sync:
93146
name: Policy schema sync with skill repo
94147
runs-on: ubuntu-latest
Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
name: npm published smoke
2+
3+
on:
4+
workflow_run:
5+
workflows: ['Publish to npm']
6+
types: [completed]
7+
workflow_dispatch:
8+
inputs:
9+
version:
10+
description: 'Published npm version to verify (defaults to package.json from checked-out commit)'
11+
required: false
12+
13+
jobs:
14+
smoke:
15+
if: >
16+
github.event_name == 'workflow_dispatch' ||
17+
(github.event.workflow_run.conclusion == 'success' &&
18+
github.event.workflow_run.event == 'release')
19+
runs-on: ubuntu-latest
20+
permissions:
21+
contents: read
22+
steps:
23+
- uses: actions/checkout@v4
24+
with:
25+
ref: ${{ github.event_name == 'workflow_run' && github.event.workflow_run.head_sha || github.sha }}
26+
27+
- uses: actions/setup-node@v4
28+
with:
29+
node-version: 20.x
30+
registry-url: https://registry.npmjs.org
31+
32+
- name: Verify credentials present
33+
env:
34+
TOKEN: ${{ secrets.SWITCHBOT_TOKEN }}
35+
SECRET: ${{ secrets.SWITCHBOT_SECRET }}
36+
run: |
37+
if [ -z "$TOKEN" ] || [ -z "$SECRET" ]; then
38+
echo "SWITCHBOT_TOKEN / SWITCHBOT_SECRET not set in repo secrets"
39+
exit 1
40+
fi
41+
42+
- name: Verify npm token present
43+
env:
44+
TOKEN: ${{ secrets.NPM_TOKEN }}
45+
run: |
46+
if [ -z "$TOKEN" ]; then
47+
echo "NPM_TOKEN not set in repo secrets"
48+
exit 1
49+
fi
50+
51+
- name: Resolve target version
52+
id: version
53+
run: |
54+
if [ -n "${{ inputs.version }}" ]; then
55+
VERSION="${{ inputs.version }}"
56+
else
57+
VERSION=$(node -p "require('./package.json').version")
58+
fi
59+
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
60+
echo "target_version=$VERSION"
61+
62+
- name: Resolve current latest dist-tag
63+
id: latest
64+
run: |
65+
LATEST=$(npm view @switchbot/openapi-cli dist-tags.latest)
66+
echo "version=$LATEST" >> "$GITHUB_OUTPUT"
67+
echo "current_latest=$LATEST"
68+
69+
- name: Wait for npm package to become available
70+
id: wait_package
71+
env:
72+
VERSION: ${{ steps.version.outputs.version }}
73+
run: |
74+
for i in $(seq 1 24); do
75+
if [ "${{ github.event_name }}" = "workflow_run" ]; then
76+
FOUND=$(npm view "@switchbot/openapi-cli@next" version 2>/dev/null || true)
77+
if [ "$FOUND" = "$VERSION" ]; then
78+
echo "npm package is available on next: $FOUND"
79+
exit 0
80+
fi
81+
echo "waiting for @switchbot/openapi-cli@$VERSION to appear on npm dist-tag next ($i/24); current next=$FOUND"
82+
else
83+
FOUND=$(npm view "@switchbot/openapi-cli@$VERSION" version 2>/dev/null || true)
84+
if [ "$FOUND" = "$VERSION" ]; then
85+
echo "npm package version is available: $FOUND"
86+
exit 0
87+
fi
88+
echo "waiting for @switchbot/openapi-cli@$VERSION to appear on npm ($i/24)"
89+
fi
90+
sleep 10
91+
done
92+
echo "Timed out waiting for @switchbot/openapi-cli@$VERSION on npm"
93+
exit 1
94+
95+
- name: Install published package in a clean temp project
96+
id: install_package
97+
env:
98+
VERSION: ${{ steps.version.outputs.version }}
99+
run: |
100+
TMPDIR=$(mktemp -d)
101+
echo "TMPDIR=$TMPDIR" >> "$GITHUB_ENV"
102+
cd "$TMPDIR"
103+
npm init -y >/dev/null 2>&1
104+
npm install "@switchbot/openapi-cli@$VERSION"
105+
106+
- name: Binary and offline smoke
107+
id: offline_smoke
108+
env:
109+
TMPDIR: ${{ env.TMPDIR }}
110+
VERSION: ${{ steps.version.outputs.version }}
111+
run: |
112+
cd "$TMPDIR"
113+
ACTUAL=$(npx --no-install switchbot --version)
114+
test "$ACTUAL" = "$VERSION"
115+
npx --no-install switchbot --help >/dev/null
116+
npx --no-install switchbot schema export --compact >/dev/null
117+
npx --no-install switchbot capabilities --json | jq -e '.data.commandMeta != null' >/dev/null
118+
119+
- name: Live smoke with configured credentials
120+
id: live_smoke
121+
env:
122+
TMPDIR: ${{ env.TMPDIR }}
123+
SWITCHBOT_TOKEN: ${{ secrets.SWITCHBOT_TOKEN }}
124+
SWITCHBOT_SECRET: ${{ secrets.SWITCHBOT_SECRET }}
125+
run: |
126+
cd "$TMPDIR"
127+
npx --no-install switchbot doctor --json | jq -e '.data.summary != null' >/dev/null
128+
npx --no-install switchbot devices list --json | jq -e '.data.deviceList != null or .data.infraredRemoteList != null' >/dev/null
129+
130+
- name: Promote verified version to latest
131+
if: success()
132+
env:
133+
VERSION: ${{ steps.version.outputs.version }}
134+
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
135+
run: |
136+
npm dist-tag add "@switchbot/openapi-cli@$VERSION" latest
137+
echo "Promoted @switchbot/openapi-cli@$VERSION to dist-tag latest"
138+
139+
- name: Deprecate failed version
140+
if: >
141+
failure() &&
142+
steps.wait_package.outcome == 'success' &&
143+
(
144+
steps.install_package.outcome == 'failure' ||
145+
steps.offline_smoke.outcome == 'failure'
146+
)
147+
env:
148+
VERSION: ${{ steps.version.outputs.version }}
149+
PREVIOUS_LATEST: ${{ steps.latest.outputs.version }}
150+
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
151+
run: |
152+
npm deprecate "@switchbot/openapi-cli@$VERSION" "Published to dist-tag next but failed package smoke tests. Install @switchbot/openapi-cli@${PREVIOUS_LATEST} or use dist-tag latest."
153+
echo "Deprecated @switchbot/openapi-cli@$VERSION after package smoke failure"
154+
155+
- name: Cleanup temp project
156+
if: always()
157+
env:
158+
TMPDIR: ${{ env.TMPDIR }}
159+
run: |
160+
if [ -n "$TMPDIR" ] && [ -d "$TMPDIR" ]; then
161+
rm -rf "$TMPDIR"
162+
fi

.github/workflows/publish.yml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,9 @@ jobs:
2828
echo "Tag $TAG_VERSION does not match package.json version $PKG_VERSION"
2929
exit 1
3030
fi
31-
- run: npm publish --provenance --access public
31+
- name: Smoke test packed npm artifact
32+
run: npm run smoke:pack-install
33+
- name: Publish package to npm dist-tag next
34+
run: npm publish --tag next --provenance --access public
3235
env:
3336
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}

CHANGELOG.md

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,35 @@ All notable changes to `@switchbot/openapi-cli` are documented in this file.
77
The format is loosely based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
88
This project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
99

10+
## [3.2.2] - 2026-04-26
11+
12+
### Changed — release pipeline
13+
14+
- Release pipeline unified: `npm run build` is now the single source for the
15+
published tarball. It runs a 5-stage `scripts/build.mjs` orchestrator
16+
(clean → typecheck → bundle → copy-assets → ensure-binary). `prepublishOnly`,
17+
`verify:pre-commit`, `verify:pre-push`, `publish.yml`, and the `bundle-smoke`
18+
/ `pack-install-smoke` CI jobs all call `npm run build` by name — no job
19+
re-implements the build steps and no other script writes to `dist/`.
20+
- Removed `npm run build:prod` and `npm run clean` — both are folded into
21+
`scripts/build.mjs`.
22+
- Added `npm run typecheck` (`tsc --noEmit`) as the local "does it still
23+
compile?" escape hatch.
24+
- Split `scripts/copy-assets.mjs` responsibility into two scripts with one
25+
failure mode each: `copy-assets.mjs` only copies policy assets, and the
26+
new `scripts/ensure-binary.mjs` asserts the shebang is present on
27+
`dist/index.js` and `chmod 0755`s it. `ensure-binary.mjs` is a regression
28+
guard — it fails loudly if the esbuild banner drops the shebang, rather
29+
than silently repairing it the way `copy-assets.mjs` used to.
30+
1031
## [3.2.1] - 2026-04-25
1132

33+
> **Deprecated on npm.** The initial `3.2.1` publish shipped a broken bin
34+
> (missing shebang / exec bit after `npm pack`). It has been rolled back
35+
> from `main` and relanded in `3.2.2`; install `@switchbot/openapi-cli@3.2.2`
36+
> or later. The feature list below is retained as the historical record of
37+
> what `3.2.1` intended to deliver and what `3.2.2` now ships.
38+
1239
### Added — plan resource model, MCP risk profiles, rules safety primitives
1340

1441
- `switchbot plan save [file]` — persist a validated plan to `~/.switchbot/plans/<planId>.json`
@@ -32,6 +59,28 @@ This project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
3259
- `rules lint` now validates `hysteresis` / `requires_stable_for` duration syntax and warns
3360
when `hysteresis` and `requires_stable_for` are both set.
3461

62+
### Changed — release pipeline
63+
64+
- **Single publish source**: `publish.yml` now runs `npm run build:prod`
65+
(esbuild) to match what `prepublishOnly` produces at `npm publish` time. The
66+
tarball validated by `smoke:pack-install` is now byte-identical to the
67+
tarball that actually ships to the registry — no artifact swap during
68+
publish.
69+
- Pre-publish `smoke:pack-install` runs in `publish.yml` before `npm publish`,
70+
and the same smoke runs locally via `pre-push` hook (`verify:pre-push`) and
71+
on every PR in CI (`pack-install-smoke`).
72+
- `scripts/copy-assets.mjs` now injects the `#!/usr/bin/env node` shebang into
73+
`dist/index.js` and chmods it to `0755` after every build, so the npm bin
74+
entry is always executable.
75+
- New `npm-published-smoke.yml` workflow verifies published tarballs on the
76+
npm registry, auto-promotes `next → latest` on success, and auto-deprecates
77+
on package-install/offline smoke failures only (never on live API flakes).
78+
- `bundle-smoke` CI job is now a blocking matrix across Node 18/20/22 (was
79+
single-node Node 20, advisory), so the esbuild bundle must start cleanly
80+
on every supported Node version before a PR can merge.
81+
- See [`docs/release-pipeline.md`](./docs/release-pipeline.md) for the full
82+
gate sequence and invariants.
83+
3584
## [3.2.0] - 2026-04-25
3685

3786
### Added — daemon, upgrade-check, scenes validate/simulate, rules summary

README.md

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ Under the hood every surface shares the same catalog, cache, and HMAC client —
9393
- 🎨 **Dual output modes** — colorized tables by default; `--json` passthrough for `jq` and scripting
9494
- 🔐 **Secure credentials** — HMAC-SHA256 signed requests; config file written with `0600`; env-var override for CI
9595
- 🔍 **Dry-run mode** — preview every mutating request before it hits the API
96-
- 🧪 **Fully tested**1882 Vitest tests, mocked axios, zero network in CI
96+
- 🧪 **Fully tested**1959 Vitest tests, mocked axios, zero network in CI
9797
-**Shell completion** — Bash / Zsh / Fish / PowerShell
9898

9999
## Requirements
@@ -894,7 +894,7 @@ Queries the npm registry for the latest published version and compares it agains
894894

895895
```json
896896
{
897-
"current": "3.2.1",
897+
"current": "3.2.2",
898898
"latest": "4.0.0",
899899
"upToDate": false,
900900
"updateAvailable": true,
@@ -1123,7 +1123,7 @@ npm install
11231123

11241124
npm run dev -- <args> # Run from TypeScript sources via tsx
11251125
npm run build # Compile to dist/
1126-
npm test # Run the Vitest suite (1882 tests)
1126+
npm test # Run the Vitest suite (1959 tests)
11271127
npm run test:watch # Watch mode
11281128
npm run test:coverage # Coverage report (v8, HTML + text)
11291129
```
@@ -1205,7 +1205,7 @@ src/
12051205
├── format.ts # renderRows / filterFields / output-format dispatch
12061206
├── audit.ts # JSONL audit log writer
12071207
└── quota.ts # Local daily-quota counter
1208-
tests/ # Vitest suite (1882 tests, mocked axios, no network)
1208+
tests/ # Vitest suite (1959 tests, mocked axios, no network)
12091209
```
12101210

12111211
### Release flow
@@ -1219,6 +1219,8 @@ git push --follow-tags
12191219

12201220
Then on GitHub → **Releases → Draft a new release → select tag → Publish**. The `publish.yml` workflow runs tests, verifies the tag matches `package.json`, and publishes `@switchbot/openapi-cli` to npm with [provenance](https://docs.npmjs.com/generating-provenance-statements).
12211221

1222+
See [`docs/release-pipeline.md`](./docs/release-pipeline.md) for the full pre-publish and post-publish verification flow (local hooks → CI → `publish.yml``npm-published-smoke.yml`).
1223+
12221224
## License
12231225

12241226
[MIT](./LICENSE) © chenliuyun

0 commit comments

Comments
 (0)