cowork-harness 0.33.0 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/skills/cowork-harness/SKILL.md +6 -5
- package/.claude/skills/cowork-harness/references/ci-recipe.md +5 -5
- package/.claude/skills/cowork-harness/references/fidelity-and-answers.md +1 -1
- package/.claude/skills/cowork-harness/references/scenario-schema.md +1 -1
- package/CHANGELOG.md +31 -0
- package/README.md +5 -5
- package/RELEASING.md +50 -4
- package/examples/replays/README.md +1 -1
- package/package.json +5 -1
- package/scripts/bump-version.ts +274 -0
- package/scripts/check-surface.ts +95 -0
- package/scripts/gen-surface.ts +28 -0
- package/scripts/lib/env-scrape.ts +35 -0
- package/scripts/lib/surface.ts +157 -0
- package/scripts/release-preflight.ts +311 -0
|
@@ -3,8 +3,8 @@ name: cowork-harness
|
|
|
3
3
|
description: Test or debug a Claude Code skill/plugin under Claude Cowork's runtime — sandboxed agent, default-deny egress, the can_use_tool permission/question protocol — using the cowork-harness CLI. Use when validating or regression-testing a skill, authoring or debugging a scenario YAML (prompt + scripted answers + assert:), choosing a fidelity tier, scripting AskUserQuestion / tool-permission answers, or asserting artifacts, egress, or sub-agent dispatch. Especially when a harness run no-ops an assertion, fails on an unanswered gate, false-greens, a steered answer never reaches the model, or a web_fetch is unexpectedly denied or gated. NOT for generic unit testing (pytest/vitest of your own scripts) or non-Cowork CI. Covers the skill / run / chat / record / replay / trace / decide / assertions / scaffold commands and the session-vs-scenario split.
|
|
4
4
|
metadata:
|
|
5
5
|
author: cowork-harness
|
|
6
|
-
version: 0.
|
|
7
|
-
tracks-harness: cowork-harness 0.
|
|
6
|
+
version: 1.0.0
|
|
7
|
+
tracks-harness: cowork-harness 1.0.0 (baseline desktop-1.20186.1)
|
|
8
8
|
---
|
|
9
9
|
|
|
10
10
|
# cowork-harness
|
|
@@ -22,7 +22,7 @@ flagged with a loud `::warning::`, not silent — auto-answer a gate, observe an
|
|
|
22
22
|
allowlist). This skill exists mostly to keep you out of those traps — the Gotchas section below is
|
|
23
23
|
the highest-value part. Read it.
|
|
24
24
|
|
|
25
|
-
> **Version note:** the facts and `file:line` pointers here track `cowork-harness 0.
|
|
25
|
+
> **Version note:** the facts and `file:line` pointers here track `cowork-harness 1.0.0` (baseline
|
|
26
26
|
> `desktop-1.20186.1`). If your checkout is newer, prefer the live `--help` and — in a repo checkout —
|
|
27
27
|
> `SPEC.md` / `docs/*.md` over this snapshot, and re-run the bundled linter.
|
|
28
28
|
|
|
@@ -39,9 +39,9 @@ Before the first command, confirm the CLI is reachable and **fail loud** (never
|
|
|
39
39
|
|
|
40
40
|
- **One-shot check.** Run `cowork-harness doctor [--tier <tier>]` first — a read-only prerequisite check that inspects Docker, the staged agent, the token, and the baseline in one pass. The bullets below explain each thing it checks (and how to fix it).
|
|
41
41
|
- **Replay-only? Skip `doctor`.** Replaying committed cassettes needs no Docker, no staged agent, and no token — and every tier's `doctor` validates the auth token (the live tiers also Docker + the staged agent), so a ✗ there is expected, not a blocker. Go straight to `cowork-harness replay <cassette>`.
|
|
42
|
-
- **CLI on PATH, recent enough?** Run `cowork-harness --version` — this skill needs **≥ 0.
|
|
42
|
+
- **CLI on PATH, recent enough?** Run `cowork-harness --version` — this skill needs **≥ 1.0.0**. If it's missing or older, prefix every command with the version floor `npx "cowork-harness@>=1.0.0" <cmd>` (Node ≥ 20), or install once with `npm i -g "cowork-harness@>=1.0.0"`. **Pin `@>=0.33.0`, never `@latest`** — `@latest` can silently fetch an older CLI and the new commands fail as "unknown command", whereas the floor **fails loud** if no compatible version is published.
|
|
43
43
|
|
|
44
|
-
What the ≥ 0.
|
|
44
|
+
What the ≥ 1.0.0 floor gates, by release:
|
|
45
45
|
|
|
46
46
|
- **core set (pre-0.21.0 vintage, or mixed):** `assertions --list`, `scaffold <run-id>`, `trace --view dispatches`, `artifact_json` incl. the `in:` operator (passes when the resolved value deep-equals one of the listed members — value ∈ your list, not the reverse), `verify-cassettes` incl. the `--allow-domain`/`--allow-email`/`--allow-patterns-file` allows (`--allow-patterns-file <path>` is a FILE of patterns, one regex per line — not a path to allow, unlike `--allow <regex>`), batch `record <dir>`/`--rerecord-stale`, `record --concurrency <N>`, record-time redaction, multiSelect/`answer:`, `verify-run` answer-coverage, `record --max-artifact-bytes`, live record-time deciders, scenario `skills:` staleness scoping with `COWORK_HARNESS_AGENT_SCOPE=skill`, `chat --plugin`, and `/help` in the REPL.
|
|
47
47
|
- **0.21.0:** `verify-cassettes --allow-path` (`path` — local absolute filesystem paths — is the scanner's 4th class), and `hostloop`'s native host/VM process split with its `allow_host_writes:` consent field.
|
|
@@ -51,6 +51,7 @@ Before the first command, confirm the CLI is reachable and **fail loud** (never
|
|
|
51
51
|
- **0.31.0:** the `lint-skill` (static host-loop footgun + `subagent_type` resolution linter) / `analyze-skill` (advisory `/sessions`-path static scan, `--strict`, `analyze-skill: ignore` marker) / `probe-dispatch` (single-dispatch mechanics probe) commands, `status --latest-for` (resolve a scenario's newest run dir by run time, not directory mtime), the `subagent_dispatch_healthy` composite assertion, and the persisted `result.json` fields `verdict`, `subagents[].referencesRead`/`subagents[].reasoning`, plus the `toolCounts`/`toolErrors`/`toolDurations` shape distinction.
|
|
52
52
|
- **0.32.0:** `analyze-skill`'s directory scan now covering a skill/plugin's full contract surface (recursive `agents/`/`references/`/`commands/`, plugin-root-aware, symlink-following) with line/block-scoped `analyze-skill: ignore-next-line`/`ignore-start`/`ignore-end` markers and multi-path/glob input, and `lint-skill`'s provable in-plugin `subagent_type` typo now a WARN that gates under `--strict`.
|
|
53
53
|
- **0.33.0:** the `redacted` marker on display-omitted reasoning — `subagents[].reasoning` and the top-level `thinking[]` now carry `{text:"", redacted:true}` when the model returns a signed-but-empty thought (so "reasoned, text omitted" is distinct from "no thought"), plus the fenced `debug.thinking_display` escape hatch.
|
|
54
|
+
- **1.0.0:** first stable release — the SPEC §12 compatibility contract takes effect (covered CLI/schema/env/Action surfaces are now stable; breaking changes need a major bump). No new author-facing command; the floor simply tracks the 1.0 release.
|
|
54
55
|
- **Agent binary (sandboxed live tiers — `container`/`microvm`/`hostloop`/`cowork`).** The staged Claude Code agent is **bind-mounted** from a local Claude Desktop install, or point `COWORK_AGENT_BINARY` at a `claude-code-vm/<ver>/claude` ELF. Nothing is bundled. `protocol` (L0) and `replay` need no staged agent; for the sandboxed tiers, no agent → no run; report that, don't skip silently.
|
|
55
56
|
- **Docker / Lima.** Only `--fidelity protocol` (L0) runs without them. `container` / `microvm` / `hostloop` / `cowork` need Docker (Lima for L2). If they're absent, drop to `--fidelity protocol` and **say so** — a green that never exercised the sandbox is not a sandbox pass.
|
|
56
57
|
- **Auth.** `CLAUDE_CODE_OAUTH_TOKEN` (preferred), or `ANTHROPIC_API_KEY` / `ANTHROPIC_AUTH_TOKEN`, via env or `.env`. Minting an OAuth token needs the **`claude` CLI** (`npm i -g @anthropic-ai/claude-code`, then `claude setup-token`).
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# CI recipe — replay vs live lanes
|
|
2
2
|
|
|
3
|
-
Self-contained reference. Tracks `cowork-harness 0.
|
|
3
|
+
Self-contained reference. Tracks `cowork-harness 1.0.0` (baseline `desktop-1.20186.1`).
|
|
4
4
|
|
|
5
5
|
**Fastest path: the packaged Action.** One step gets you `replay`/`lint`/`verify-cassettes` plus a PR
|
|
6
6
|
job-summary reporter (verdict table, staleness findings, cost/turns when available):
|
|
@@ -13,7 +13,7 @@ job-summary reporter (verdict table, staleness findings, cost/turns when availab
|
|
|
13
13
|
```
|
|
14
14
|
|
|
15
15
|
The Action's `version` input defaults to `latest` — intentional so a copy-pasted recipe tracks the current
|
|
16
|
-
release; pin an exact version (e.g. `version: "0.
|
|
16
|
+
release; pin an exact version (e.g. `version: "1.0.0"`) for reproducible CI.
|
|
17
17
|
|
|
18
18
|
Reach for the manual multi-step form below only when you need per-step control the Action's inputs don't
|
|
19
19
|
cover (a custom flag combination, a different runner matrix per step, or `lint`/`verify-cassettes` gated
|
|
@@ -57,7 +57,7 @@ sha256-*checked* but not hard-blocking on mismatch — it's advisory for an inte
|
|
|
57
57
|
GitHub-hosted runners, no token/Docker/agent:
|
|
58
58
|
|
|
59
59
|
```yaml
|
|
60
|
-
- run: npm i -g "cowork-harness@>=0.
|
|
60
|
+
- run: npm i -g "cowork-harness@>=1.0.0"
|
|
61
61
|
- run: cowork-harness lint scenarios/*.yaml # no silent false-greens
|
|
62
62
|
- run: cowork-harness verify-cassettes cassettes/ # privacy + staleness
|
|
63
63
|
- run: cowork-harness replay cassettes/ # token-free content/structure
|
|
@@ -197,7 +197,7 @@ jobs:
|
|
|
197
197
|
with: { node-version: '20' }
|
|
198
198
|
- uses: actions/setup-python@v5
|
|
199
199
|
with: { python-version: '3.x' } # python3 only — PyYAML is bundled with the linter
|
|
200
|
-
- run: npm i -g "cowork-harness@>=0.
|
|
200
|
+
- run: npm i -g "cowork-harness@>=1.0.0"
|
|
201
201
|
- run: cowork-harness lint scenarios/*.yaml # no-silent-false-green (needs python3; PyYAML bundled)
|
|
202
202
|
- run: cowork-harness verify-cassettes cassettes/ --output-format json # privacy + staleness gate
|
|
203
203
|
- run: cowork-harness replay cassettes/ --output-format json # token-free content/structure
|
|
@@ -226,7 +226,7 @@ jobs:
|
|
|
226
226
|
echo "live=true" >> "$GITHUB_OUTPUT"
|
|
227
227
|
fi
|
|
228
228
|
- if: steps.guard.outputs.live == 'true'
|
|
229
|
-
run: npm i -g "cowork-harness@>=0.
|
|
229
|
+
run: npm i -g "cowork-harness@>=1.0.0"
|
|
230
230
|
- if: steps.guard.outputs.live == 'true'
|
|
231
231
|
run: cowork-harness run scenarios/ --output-format json
|
|
232
232
|
env:
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Fidelity tiers & answer paths
|
|
2
2
|
|
|
3
|
-
Self-contained reference. Tracks `cowork-harness 0.
|
|
3
|
+
Self-contained reference. Tracks `cowork-harness 1.0.0` (baseline `desktop-1.20186.1`).
|
|
4
4
|
|
|
5
5
|
## Fidelity tiers (`fidelity:` in the scenario)
|
|
6
6
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Scenario & session schema, assertion catalog, web_fetch, full gotchas
|
|
2
2
|
|
|
3
|
-
Self-contained reference for authoring `cowork-harness` scenarios. Tracks `cowork-harness 0.
|
|
3
|
+
Self-contained reference for authoring `cowork-harness` scenarios. Tracks `cowork-harness 1.0.0`
|
|
4
4
|
(baseline `desktop-1.20186.1`). If your checkout is newer, prefer the live `docs/scenario.md`,
|
|
5
5
|
`docs/session.md`, and `SPEC.md`.
|
|
6
6
|
|
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,37 @@ All notable changes to this project are documented here. The format is based on
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [1.0.0] — 2026-07-13
|
|
10
|
+
|
|
11
|
+
**First stable release.** The compatibility contract in
|
|
12
|
+
[SPEC.md §12](./SPEC.md#12-versioning--the-10-compatibility-contract) is now in force: the covered
|
|
13
|
+
surfaces — the CLI commands + exit codes, the scenario / session / baseline / run-result / cassette /
|
|
14
|
+
protocol schemas, the documented environment variables, and the packaged Action's inputs/outputs — are
|
|
15
|
+
stable, and a breaking change to any of them requires a major version bump. Human-readable text output
|
|
16
|
+
is explicitly not covered. There is **no runtime behavior change from `0.33.0`** — `1.0.0` blesses that
|
|
17
|
+
runtime as stable and adds the release-engineering guards below.
|
|
18
|
+
|
|
19
|
+
### Added
|
|
20
|
+
|
|
21
|
+
- **Surface-contract guard** (`test/surface-contract.test.ts`, `npm run gen:surface` /
|
|
22
|
+
`npm run check:surface`) — snapshots the structured §12 surfaces (every `schema/*.json`'s field paths
|
|
23
|
+
and enums including exit codes, `action.yml` inputs/outputs, and the documented `COWORK_*` env-var
|
|
24
|
+
set) into `test/fixtures/surface-baseline.json`; CI reds on undocumented drift so a covered-surface
|
|
25
|
+
change can't ship silently. The CLI/exit-code-semantics/`PlatformBaseline` surfaces are frozen via a
|
|
26
|
+
manual review step in `RELEASING.md`.
|
|
27
|
+
- **`npm run bump -- X.Y.Z --write`** — rewrites every version location via targeted patterns (dry-run
|
|
28
|
+
by default), leaving historical release notes, the baseline pin, and the `V=` agent pins untouched;
|
|
29
|
+
self-checks `check:versions`.
|
|
30
|
+
- **`npm run preflight`** — a local pre-release gate (`check:versions`, CHANGELOG heading, unused tag,
|
|
31
|
+
clean tree, live-key reminder); `--for-tag` additionally asserts `HEAD == origin/main` and a green
|
|
32
|
+
push-event CI run for `HEAD`, mechanically preventing a tag on the wrong commit.
|
|
33
|
+
|
|
34
|
+
### Changed
|
|
35
|
+
|
|
36
|
+
- CI marks a skipped live scenario suite with a loud "NOT live-validated" job-summary banner; the
|
|
37
|
+
release docs (`RELEASING.md`, `release.yml`) now state that a release tag must go on the merge commit
|
|
38
|
+
(the SHA with a push-event CI run), with recovery steps.
|
|
39
|
+
|
|
9
40
|
## [0.33.0] — 2026-07-13
|
|
10
41
|
|
|
11
42
|
### Fixed
|
package/README.md
CHANGED
|
@@ -91,7 +91,7 @@ node dist/cli.js replay examples/replays/example-pdf-skill.cassette.json
|
|
|
91
91
|
|
|
92
92
|
> **Installed globally instead?** Once linked/installed, the same command is `cowork-harness replay
|
|
93
93
|
> <cassette>` — but the relative path above only resolves from a source checkout's `examples/replays/`.
|
|
94
|
-
> From a global install (`npm i -g "cowork-harness@>=0.
|
|
94
|
+
> From a global install (`npm i -g "cowork-harness@>=1.0.0"`), point at the package root instead:
|
|
95
95
|
> `cowork-harness replay "$(npm root -g)/cowork-harness/examples/replays/example-pdf-skill.cassette.json"`
|
|
96
96
|
> (or copy the cassette into your own project and pass that path).
|
|
97
97
|
|
|
@@ -101,7 +101,7 @@ Live `run`/`skill` need the prerequisites in the next section — note the `prot
|
|
|
101
101
|
> - **Replay only (zero setup):** `cowork-harness replay <cassette>` — no token, no Docker, no agent. The command above.
|
|
102
102
|
> - **`protocol` (real model, no Docker):** needs only the auth token (item 3 below).
|
|
103
103
|
> - **Live `container` / `microvm` / `hostloop` / `cowork`:** needs Docker (or Lima for `microvm`), a staged agent, and the token — run `cowork-harness doctor` first.
|
|
104
|
-
> - **Invocation:** from a source checkout, `node dist/cli.js <cmd>` (or `npm link` to get the `cowork-harness` command); from a global install, `cowork-harness <cmd>`; the companion skill falls back to `npx "cowork-harness@>=0.
|
|
104
|
+
> - **Invocation:** from a source checkout, `node dist/cli.js <cmd>` (or `npm link` to get the `cowork-harness` command); from a global install, `cowork-harness <cmd>`; the companion skill falls back to `npx "cowork-harness@>=1.0.0"`.
|
|
105
105
|
|
|
106
106
|
Two more worked examples worth knowing about: `examples/scenarios/protocol-smoke.yaml` (zero-Docker smoke
|
|
107
107
|
test) and `examples/scenarios/skill-loads.yaml` (container-tier acceptance check) — see
|
|
@@ -126,7 +126,7 @@ claude plugin marketplace add yaniv-golan/cowork-harness
|
|
|
126
126
|
claude plugin install cowork-harness@cowork-harness
|
|
127
127
|
```
|
|
128
128
|
|
|
129
|
-
The skill **self-bootstraps the CLI**: if `cowork-harness` isn't on your PATH it falls back to `npx "cowork-harness@>=0.
|
|
129
|
+
The skill **self-bootstraps the CLI**: if `cowork-harness` isn't on your PATH it falls back to `npx "cowork-harness@>=1.0.0"` (a version floor that fails loud rather than silently fetching a too-old CLI; Node ≥ 20). Tiers above `protocol` still need Docker/Lima and a Claude Desktop agent binary — see the prerequisites below.
|
|
130
130
|
|
|
131
131
|
It also follows the open [Agent Skills](https://agentskills.io) spec, so it installs cross-editor (Cursor, Codex, OpenCode, …) via [`npx skills`](https://github.com/vercel-labs/skills) (Vercel Labs' CLI implementation of that spec):
|
|
132
132
|
|
|
@@ -147,7 +147,7 @@ A global install is enough for CI `lint`, reading the teaching skill, and replay
|
|
|
147
147
|
To `run` the worked examples live or copy them as a starting point, use a source checkout. (The marketplace
|
|
148
148
|
skill install itself only pulls `.claude/skills/cowork-harness/` — SKILL.md + `references/` + `scenario.py`/
|
|
149
149
|
assertion keys, per `.claude-plugin/marketplace.json`'s `source` — not the rest of this table; the full set
|
|
150
|
-
above becomes available once the skill's first command self-bootstraps `npx "cowork-harness@>=0.
|
|
150
|
+
above becomes available once the skill's first command self-bootstraps `npx "cowork-harness@>=1.0.0"` — see
|
|
151
151
|
[above](#drive-it-from-claude-code-companion-skill) — which pulls the same npm package as the global-install row.)
|
|
152
152
|
|
|
153
153
|
### Prerequisites for anything above `protocol` fidelity
|
|
@@ -665,7 +665,7 @@ jobs:
|
|
|
665
665
|
anthropic-api-key: ${{ secrets.ANTHROPIC_API_KEY }}
|
|
666
666
|
```
|
|
667
667
|
|
|
668
|
-
Every run writes a Markdown verdict table (scenario, pass/fail, signals, cost/turns when available, staleness findings, and the replay-skipped-assertions honesty line) to the job summary. Inputs: `command`, `path` (required), `version` (npm dist-tag/version, default `latest` — intentional so recipes track the current release; pin an exact version for reproducible CI. The companion skill's
|
|
668
|
+
Every run writes a Markdown verdict table (scenario, pass/fail, signals, cost/turns when available, staleness findings, and the replay-skipped-assertions honesty line) to the job summary. Inputs: `command`, `path` (required), `version` (npm dist-tag/version, default `latest` — intentional so recipes track the current release; pin an exact version for reproducible CI. The companion skill's `cowork-harness@>=1.0.0` floor guidance applies to ad-hoc CLI installs, not this input), `strict` (applies to `replay` (staleness findings), `lint`/`lint-skill` (WARN/INFO), and `analyze-skill` (any advisory finding); IGNORED — not forwarded — for `verify-cassettes`/`run`, which don't accept the flag), `fail-on-skill-drift` (**`replay`-only** — never forwarded to the analyzers), `extra-args`, `summary` (default `true`), `anthropic-api-key` (live lane only). See [`action.yml`](./action.yml) for the full input reference.
|
|
669
669
|
|
|
670
670
|
The provided [GitHub Actions workflow](.github/workflows/ci.yml) runs a **six-stage pipeline**. The **unit** stage is the token-free gate you can copy into your skill repo; the `action-self-test`, `python`, `boundary`, `scenarios`, and `parity-drift` stages are this repo's own fidelity self-tests and are not directly portable (they build the harness's Docker image and run harness-specific e2e scenarios — see [`ci-recipe.md`](./.claude/skills/cowork-harness/references/ci-recipe.md) for the skill-repo template):
|
|
671
671
|
|
package/RELEASING.md
CHANGED
|
@@ -64,6 +64,21 @@ Never push the tag before CI is green for the exact commit you intend to tag. Th
|
|
|
64
64
|
enforces this (`Require ci.yml success for this commit` step), but don't rely on it — tag a green
|
|
65
65
|
SHA.
|
|
66
66
|
|
|
67
|
+
**Tag the MERGE COMMIT (main HEAD after the merge), never the release-branch head — and here's why.**
|
|
68
|
+
The publish gate (`require-ci-success`) queries `ci.yml` runs with `--event push` for the tagged SHA.
|
|
69
|
+
`ci.yml` only triggers `on: push` for **`main`** (plus `pull_request` / `workflow_dispatch`), so a
|
|
70
|
+
release-branch/PR head has *only* a `pull_request` run — which the `--event push` filter ignores.
|
|
71
|
+
Tagging that SHA makes the gate poll ~30 min and then FAIL. Only the merge commit (produced by
|
|
72
|
+
`gh pr merge`, then `git pull`ed onto `main`) has a push-event `ci.yml` run. This is why Phase 3 tags
|
|
73
|
+
`main` HEAD after the merge — do **not** "optimize" by tagging the branch commit whose PR CI you just
|
|
74
|
+
watched go green.
|
|
75
|
+
|
|
76
|
+
When you query runs by SHA, use the **full 40-char SHA** (`git rev-parse HEAD`) —
|
|
77
|
+
`gh run list --commit <short-sha>` silently returns empty. If you mis-tag: `git push origin
|
|
78
|
+
:refs/tags/vX.Y.Z && git tag -d vX.Y.Z`, re-tag on `main` HEAD, re-push, and cancel the misfired
|
|
79
|
+
release run. Running `npm run preflight -- --for-tag` right before the tag push mechanically catches
|
|
80
|
+
this (it asserts `HEAD == origin/main` and that a push-event `ci.yml` run succeeded for `HEAD`).
|
|
81
|
+
|
|
67
82
|
## Versioning (semver, pre-1.0)
|
|
68
83
|
|
|
69
84
|
Pre-1.0: **minor** (`0.N+1.0`) = new features and/or behavior changes; **patch** (`0.N.M+1`) =
|
|
@@ -75,8 +90,28 @@ From `1.0.0`, semver is enforced against the **covered surfaces enumerated in
|
|
|
75
90
|
scenario/session/baseline/run-result/cassette/protocol schemas, the documented env vars, and the
|
|
76
91
|
packaged Action's inputs/outputs). Human-readable text output is explicitly NOT covered.
|
|
77
92
|
|
|
93
|
+
**Surface drift is partly automated.** `test/surface-contract.test.ts` snapshots the *structured*
|
|
94
|
+
surfaces — every `schema/*.json` (field paths + enums, including exit-code enums), `action.yml`
|
|
95
|
+
inputs/outputs, and the documented `COWORK_*` env-var set — into `test/fixtures/surface-baseline.json`.
|
|
96
|
+
Any change to those reds CI until you regenerate (`npm run gen:surface`) and review the diff; at `1.0.0`
|
|
97
|
+
a *removal or type/enum change* means a **major** bump. `npm run check:surface` prints the
|
|
98
|
+
added/removed/changed breakdown.
|
|
99
|
+
|
|
100
|
+
**1.0.0 surface-freeze review (one-time, MANUAL — the surfaces the snapshot can't cover).** Before
|
|
101
|
+
tagging `1.0.0`, deliberately review and freeze the surfaces with no machine-readable source:
|
|
102
|
+
- **CLI command + flag surface** — walk `cowork-harness --help` per command; confirm no command/flag is
|
|
103
|
+
removed or repurposed vs `0.x` intent. (No structured source exists — `cli-structural-guard`'s `CASES`
|
|
104
|
+
and `cli-help`'s pinned strings are hand-maintained.)
|
|
105
|
+
- **Per-command exit-code semantics** (SPEC §11) — confirm the documented meanings are the ones you
|
|
106
|
+
intend to hold stable.
|
|
107
|
+
- **The `PlatformBaseline` shape** (Zod in `src/types.ts`; no `schema/*.json`).
|
|
108
|
+
|
|
78
109
|
## Version locations — bump ALL of these to the same `X.Y.Z`
|
|
79
110
|
|
|
111
|
+
> **`npm run bump -- X.Y.Z --write` automates this whole section** (targeted patterns + lockfile +
|
|
112
|
+
> `check:versions`). The list below documents *what it touches* — keep it accurate if you add a new
|
|
113
|
+
> version-bearing string, and add that string to `scripts/bump-version.ts` too.
|
|
114
|
+
|
|
80
115
|
1. `package.json` → `"version"` (then run `npm install` to update `package-lock.json`).
|
|
81
116
|
2. `.claude-plugin/marketplace.json` → `plugins[0].version`.
|
|
82
117
|
3. `.claude/skills/cowork-harness/.claude-plugin/plugin.json` → `"version"`.
|
|
@@ -92,8 +127,9 @@ packaged Action's inputs/outputs). Human-readable text output is explicitly NOT
|
|
|
92
127
|
8. `.claude/skills/cowork-harness/references/ci-recipe.md` → all `npm i -g "cowork-harness@>=X.Y.Z"` floors
|
|
93
128
|
(currently 3 occurrences).
|
|
94
129
|
9. `examples/replays/README.md` → the `npm i -g "cowork-harness@>=X.Y.Z"` floor.
|
|
95
|
-
10. `README.md` →
|
|
96
|
-
|
|
130
|
+
10. `README.md` → every `cowork-harness@>=X.Y.Z` floor (the bootstrap-fallback `npx`/`npm i -g` lines
|
|
131
|
+
plus the Action-inputs "companion skill's floor guidance" mention). The `check:versions` lockstep
|
|
132
|
+
guard enforces these match the SKILL.md floor and will red CI otherwise.
|
|
97
133
|
|
|
98
134
|
## Checklist
|
|
99
135
|
|
|
@@ -101,7 +137,14 @@ packaged Action's inputs/outputs). Human-readable text output is explicitly NOT
|
|
|
101
137
|
- [ ] **CHANGELOG.md** — move everything under `## [Unreleased]` into a new
|
|
102
138
|
`## [X.Y.Z] — YYYY-MM-DD` section; leave an empty `## [Unreleased]` on top. Include any
|
|
103
139
|
**upgrade notes** (e.g. "re-record cassettes after the staleness-hash change").
|
|
104
|
-
- [ ] Bump every version location
|
|
140
|
+
- [ ] Bump every version location (items 1–10) with **`npm run bump -- X.Y.Z --write`** — it rewrites all
|
|
141
|
+
of them via targeted patterns and updates the lockfile + self-checks `check:versions` (run without
|
|
142
|
+
`--write` first to preview the diff; dry-run is the default). It deliberately does **not** touch the
|
|
143
|
+
CHANGELOG or add the SKILL.md `- **X.Y.Z:**` release-note bullet — do the CHANGELOG move (above) and
|
|
144
|
+
add that bullet by hand.
|
|
145
|
+
- [ ] `npm run preflight` — local pre-release gate (`check:versions`, CHANGELOG heading present + non-empty,
|
|
146
|
+
tag `vX.Y.Z` not already used, clean tree; warns if the `ANTHROPIC_API_KEY` repo secret is missing so
|
|
147
|
+
the push-to-main live suite would need the `SKIP_LIVE_SCENARIOS` override — see §9).
|
|
105
148
|
- [ ] `npm run format:check` — fix any issues (`npx prettier --write "src/**/*.ts" "test/**/*.ts"`).
|
|
106
149
|
A format failure is the most common first-pass CI red.
|
|
107
150
|
- [ ] `npx tsc -p tsconfig.test.json --noEmit` — typecheck including tests.
|
|
@@ -128,8 +171,11 @@ packaged Action's inputs/outputs). Human-readable text output is explicitly NOT
|
|
|
128
171
|
git checkout main && git pull origin main
|
|
129
172
|
git push origin main
|
|
130
173
|
```
|
|
131
|
-
- [ ] **Phase 3 — tag and publish
|
|
174
|
+
- [ ] **Phase 3 — tag and publish** (tag the MERGE COMMIT = current `main` HEAD, per the "why" above):
|
|
132
175
|
```
|
|
176
|
+
git checkout main && git pull origin main
|
|
177
|
+
npm run preflight -- --for-tag # asserts HEAD==origin/main AND a green push-event ci.yml run for HEAD
|
|
178
|
+
git tag vX.Y.Z # on main HEAD (the merge commit)
|
|
133
179
|
git push origin vX.Y.Z
|
|
134
180
|
gh run watch $(gh run list --workflow=release.yml --limit 1 --json databaseId --jq '.[0].databaseId')
|
|
135
181
|
```
|
|
@@ -16,7 +16,7 @@ DOES exercise a real gate exchange, see `example-multiselect-gate.cassette.json`
|
|
|
16
16
|
|
|
17
17
|
Run it with:
|
|
18
18
|
|
|
19
|
-
> Assumes the `cowork-harness` CLI is available — from a source checkout run `npm ci && npm run build && npm link` first, or `npm i -g "cowork-harness@>=0.
|
|
19
|
+
> Assumes the `cowork-harness` CLI is available — from a source checkout run `npm ci && npm run build && npm link` first, or `npm i -g "cowork-harness@>=1.0.0"`. (`replay` itself needs nothing else — no token, no Docker.)
|
|
20
20
|
|
|
21
21
|
```sh
|
|
22
22
|
cowork-harness replay examples/replays/example-pdf-skill.cassette.json
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cowork-harness",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "1.0.0",
|
|
4
4
|
"description": "Scriptable, CI-friendly harness for Claude Cowork's runtime contract for testing skills across scenarios — same agent, mounts, egress allowlist, permission protocol, and sandbox limitations.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -91,6 +91,10 @@
|
|
|
91
91
|
"prepack": "find .claude/skills/cowork-harness/scripts/_vendor -name __pycache__ -type d -prune -exec rm -rf {} + 2>/dev/null || true",
|
|
92
92
|
"prepublishOnly": "npm run ci",
|
|
93
93
|
"check:versions": "tsx scripts/check-versions.ts",
|
|
94
|
+
"bump": "tsx scripts/bump-version.ts",
|
|
95
|
+
"preflight": "tsx scripts/release-preflight.ts",
|
|
96
|
+
"gen:surface": "tsx scripts/gen-surface.ts",
|
|
97
|
+
"check:surface": "tsx scripts/check-surface.ts",
|
|
94
98
|
"eval-gate": "tsx scripts/eval-gate.ts",
|
|
95
99
|
"skill-critique": "tsx scripts/skill-critique.ts",
|
|
96
100
|
"skill-critique-acceptance": "tsx scripts/skill-critique-acceptance.ts"
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
// Bumps every hand-maintained "cowork-harness X.Y.Z" version mention across the repo via targeted,
|
|
2
|
+
// pattern-based edits — NOT a blind old->new string replace, which would corrupt historical
|
|
3
|
+
// release-note bullets ("- **0.33.0:** the redacted marker…") and prose ("the loop 0.33.0's
|
|
4
|
+
// observability…"). Dry-run is the DEFAULT; --write is required to modify files.
|
|
5
|
+
//
|
|
6
|
+
// tsx scripts/bump-version.ts <X.Y.Z> # dry-run: print the diff summary, write nothing
|
|
7
|
+
// tsx scripts/bump-version.ts <X.Y.Z> --write # write files, sync lockfile, self-verify
|
|
8
|
+
//
|
|
9
|
+
// (Deliberately no --dry-run flag: `npm run bump X --dry-run` would silently drop the flag — npm
|
|
10
|
+
// eats it unless forwarded via `--` — and do a REAL bump. Default-safe avoids that trap.)
|
|
11
|
+
//
|
|
12
|
+
// This script intentionally does NOT touch: SKILL.md's `- **X.Y.Z:** …` release-note bullets, the
|
|
13
|
+
// "the loop X.Y.Z's observability" prose, CHANGELOG.md per-release headings, anything under
|
|
14
|
+
// baselines/, or the `V=X.Y.Z` agent-binary pins (those track the baseline agentVersion, not the
|
|
15
|
+
// harness version — see check-versions.ts invariant 8). The CHANGELOG `[Unreleased]` -> `[X] — DATE`
|
|
16
|
+
// move and the new SKILL.md release-note bullet are also NOT automated here — both are content, not
|
|
17
|
+
// mechanical substitution; main() prints a reminder.
|
|
18
|
+
//
|
|
19
|
+
// See docs/internal/2026-07-13-release-process-improvements-plan.md (P3) for the design and the
|
|
20
|
+
// adversarial-review fixes folded in (dry-run-by-default, the README bare `@>=X` floor, the
|
|
21
|
+
// current-version-bullet-survives test).
|
|
22
|
+
|
|
23
|
+
import { execSync } from "node:child_process";
|
|
24
|
+
import { readFileSync, writeFileSync } from "node:fs";
|
|
25
|
+
import { dirname, join } from "node:path";
|
|
26
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
27
|
+
import { checkVersions } from "./check-versions.js";
|
|
28
|
+
|
|
29
|
+
const REPO_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
|
|
30
|
+
const r = (p: string) => readFileSync(join(REPO_ROOT, p), "utf8");
|
|
31
|
+
|
|
32
|
+
const SEMVER = /^\d+\.\d+\.\d+$/;
|
|
33
|
+
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
// Pattern-level rewrites. Each is scoped to the exact surrounding context it targets so a bare
|
|
36
|
+
// version number in unrelated prose (a release-note bullet, a baseline pin, a Node-version mention)
|
|
37
|
+
// never matches.
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
|
|
40
|
+
/** Every `cowork-harness@>=X.Y.Z` floor. */
|
|
41
|
+
function bumpHarnessFloors(content: string, newVersion: string): string {
|
|
42
|
+
return content.replace(/cowork-harness@>=\d+\.\d+\.\d+/g, `cowork-harness@>=${newVersion}`);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** A bare, backtick-delimited `` `@>=X.Y.Z` `` floor with no `cowork-harness` prefix (README.md only). */
|
|
46
|
+
function bumpBareFloors(content: string, newVersion: string): string {
|
|
47
|
+
return content.replace(/`@>=\d+\.\d+\.\d+`/g, `\`@>=${newVersion}\``);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The single `"version": "X.Y.Z"` JSON field in a file that carries exactly one such key. */
|
|
51
|
+
function bumpJsonVersionField(content: string, newVersion: string): string {
|
|
52
|
+
return content.replace(/"version":\s*"\d+\.\d+\.\d+"/, `"version": "${newVersion}"`);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** SKILL.md frontmatter `version:` line. */
|
|
56
|
+
function bumpFrontmatterVersion(content: string, newVersion: string): string {
|
|
57
|
+
return content.replace(/^(\s*version:\s*)\d+\.\d+\.\d+(\s*)$/m, `$1${newVersion}$2`);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* SKILL.md `tracks-harness: cowork-harness X.Y.Z (baseline desktop-A.B.C)` line — bumps only the
|
|
62
|
+
* harness-version token immediately after `cowork-harness `, leaving the `(baseline …)` suffix
|
|
63
|
+
* completely untouched.
|
|
64
|
+
*/
|
|
65
|
+
function bumpTracksHarnessLine(content: string, newVersion: string): string {
|
|
66
|
+
return content.replace(/(tracks-harness:\s*cowork-harness\s+)\d+\.\d+\.\d+/, `$1${newVersion}`);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** SKILL.md `**Version note:** … track \`cowork-harness X.Y.Z\`` line. */
|
|
70
|
+
function bumpVersionNoteLine(content: string, newVersion: string): string {
|
|
71
|
+
return content.replace(/(track `cowork-harness )\d+\.\d+\.\d+(`)/, `$1${newVersion}$2`);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** SKILL.md `needs **≥ X.Y.Z**` sentence. */
|
|
75
|
+
function bumpNeedsFloor(content: string, newVersion: string): string {
|
|
76
|
+
return content.replace(/(needs \*\*≥ )\d+\.\d+\.\d+(\*\*)/, `$1${newVersion}$2`);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** SKILL.md `What the ≥ X.Y.Z floor gates` heading. */
|
|
80
|
+
function bumpFloorGatesHeading(content: string, newVersion: string): string {
|
|
81
|
+
return content.replace(/(What the ≥ )\d+\.\d+\.\d+( floor gates)/, `$1${newVersion}$2`);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** references/*.md `` Tracks `cowork-harness X.Y.Z` `` stamp. */
|
|
85
|
+
function bumpTracksStamp(content: string, newVersion: string): string {
|
|
86
|
+
return content.replace(/Tracks `cowork-harness \d+\.\d+\.\d+`/g, `Tracks \`cowork-harness ${newVersion}\``);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** ci-recipe.md's `` e.g. `version: "X.Y.Z"` `` example. */
|
|
90
|
+
function bumpCiRecipeExample(content: string, newVersion: string): string {
|
|
91
|
+
return content.replace(/(e\.g\. `version: ")\d+\.\d+\.\d+(")/, `$1${newVersion}$2`);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ---------------------------------------------------------------------------
|
|
95
|
+
// Per-file composition. Each target file gets exactly the pattern set the release-process plan
|
|
96
|
+
// (P3) specifies for it — never a blanket regex applied to every file, which would corrupt
|
|
97
|
+
// unrelated version-shaped mentions the plan explicitly calls out (README.md's `Node ≥ 20` and
|
|
98
|
+
// `≥1.14271.0` baseline mentions, for example).
|
|
99
|
+
// ---------------------------------------------------------------------------
|
|
100
|
+
|
|
101
|
+
const SKILL_MD = ".claude/skills/cowork-harness/SKILL.md";
|
|
102
|
+
const CI_RECIPE_MD = ".claude/skills/cowork-harness/references/ci-recipe.md";
|
|
103
|
+
const SCENARIO_SCHEMA_MD = ".claude/skills/cowork-harness/references/scenario-schema.md";
|
|
104
|
+
const FIDELITY_AND_ANSWERS_MD = ".claude/skills/cowork-harness/references/fidelity-and-answers.md";
|
|
105
|
+
const PLUGIN_JSON = ".claude/skills/cowork-harness/.claude-plugin/plugin.json";
|
|
106
|
+
const MARKETPLACE_JSON = ".claude-plugin/marketplace.json";
|
|
107
|
+
const REPLAYS_README = "examples/replays/README.md";
|
|
108
|
+
|
|
109
|
+
/** Files this script knows how to edit, in the order they're reported. */
|
|
110
|
+
export const TARGET_FILES: readonly string[] = [
|
|
111
|
+
"package.json",
|
|
112
|
+
MARKETPLACE_JSON,
|
|
113
|
+
PLUGIN_JSON,
|
|
114
|
+
SKILL_MD,
|
|
115
|
+
SCENARIO_SCHEMA_MD,
|
|
116
|
+
FIDELITY_AND_ANSWERS_MD,
|
|
117
|
+
CI_RECIPE_MD,
|
|
118
|
+
REPLAYS_README,
|
|
119
|
+
"README.md",
|
|
120
|
+
];
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Pure: computes the new content for one file. Never reads or writes anything itself, so it can be
|
|
124
|
+
* exercised directly on fixture strings in tests without touching the repo.
|
|
125
|
+
*/
|
|
126
|
+
export function rewriteFileContent(relPath: string, content: string, newVersion: string): string {
|
|
127
|
+
switch (relPath) {
|
|
128
|
+
case "package.json":
|
|
129
|
+
case MARKETPLACE_JSON:
|
|
130
|
+
case PLUGIN_JSON:
|
|
131
|
+
return bumpJsonVersionField(content, newVersion);
|
|
132
|
+
|
|
133
|
+
case SKILL_MD: {
|
|
134
|
+
let next = content;
|
|
135
|
+
next = bumpFrontmatterVersion(next, newVersion);
|
|
136
|
+
next = bumpTracksHarnessLine(next, newVersion);
|
|
137
|
+
next = bumpVersionNoteLine(next, newVersion);
|
|
138
|
+
next = bumpNeedsFloor(next, newVersion);
|
|
139
|
+
next = bumpFloorGatesHeading(next, newVersion);
|
|
140
|
+
next = bumpHarnessFloors(next, newVersion);
|
|
141
|
+
return next;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
case SCENARIO_SCHEMA_MD:
|
|
145
|
+
case FIDELITY_AND_ANSWERS_MD:
|
|
146
|
+
return bumpTracksStamp(content, newVersion);
|
|
147
|
+
|
|
148
|
+
case CI_RECIPE_MD: {
|
|
149
|
+
let next = content;
|
|
150
|
+
next = bumpTracksStamp(next, newVersion);
|
|
151
|
+
next = bumpCiRecipeExample(next, newVersion);
|
|
152
|
+
next = bumpHarnessFloors(next, newVersion);
|
|
153
|
+
return next;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
case REPLAYS_README:
|
|
157
|
+
return bumpHarnessFloors(content, newVersion);
|
|
158
|
+
|
|
159
|
+
case "README.md": {
|
|
160
|
+
let next = content;
|
|
161
|
+
next = bumpHarnessFloors(next, newVersion);
|
|
162
|
+
next = bumpBareFloors(next, newVersion);
|
|
163
|
+
return next;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
default:
|
|
167
|
+
throw new Error(`bump-version: no rewrite rule registered for "${relPath}"`);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export interface FileEdit {
|
|
172
|
+
file: string;
|
|
173
|
+
before: string;
|
|
174
|
+
after: string;
|
|
175
|
+
changed: boolean;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Reads every target file off disk and computes its planned edit. Read-only — no writes. */
|
|
179
|
+
export function planEdits(newVersion: string): FileEdit[] {
|
|
180
|
+
return TARGET_FILES.map((file) => {
|
|
181
|
+
const before = r(file);
|
|
182
|
+
const after = rewriteFileContent(file, before, newVersion);
|
|
183
|
+
return { file, before, after, changed: before !== after };
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// ---------------------------------------------------------------------------
|
|
188
|
+
// CLI
|
|
189
|
+
// ---------------------------------------------------------------------------
|
|
190
|
+
|
|
191
|
+
export function parseArgs(argv: string[]): { version: string; write: boolean } {
|
|
192
|
+
const write = argv.includes("--write");
|
|
193
|
+
const positional = argv.filter((a) => !a.startsWith("--"));
|
|
194
|
+
const version = positional[0];
|
|
195
|
+
if (!version || !SEMVER.test(version)) {
|
|
196
|
+
throw new Error(
|
|
197
|
+
`expected an X.Y.Z version as the first argument, got ${JSON.stringify(version ?? "")}. ` +
|
|
198
|
+
`Usage: tsx scripts/bump-version.ts <X.Y.Z> [--write]`,
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
return { version, write };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Rough per-file line-diff count for the human-readable summary — not used for correctness. */
|
|
205
|
+
function countChangedLines(before: string, after: string): number {
|
|
206
|
+
const b = before.split("\n");
|
|
207
|
+
const a = after.split("\n");
|
|
208
|
+
let n = 0;
|
|
209
|
+
const max = Math.max(b.length, a.length);
|
|
210
|
+
for (let i = 0; i < max; i++) if (b[i] !== a[i]) n++;
|
|
211
|
+
return n;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function main(): void {
|
|
215
|
+
let version: string;
|
|
216
|
+
let write: boolean;
|
|
217
|
+
try {
|
|
218
|
+
({ version, write } = parseArgs(process.argv.slice(2)));
|
|
219
|
+
} catch (err) {
|
|
220
|
+
process.stderr.write(`::error::bump-version: ${(err as Error).message}\n`);
|
|
221
|
+
process.exitCode = 1;
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const edits = planEdits(version);
|
|
226
|
+
const changed = edits.filter((e) => e.changed);
|
|
227
|
+
|
|
228
|
+
process.stdout.write(`bump-version — target ${version} (${write ? "--write" : "dry-run; pass --write to modify files"})\n\n`);
|
|
229
|
+
for (const e of edits) {
|
|
230
|
+
if (e.changed) {
|
|
231
|
+
process.stdout.write(`~ ${e.file} — ${countChangedLines(e.before, e.after)} line(s) changed\n`);
|
|
232
|
+
} else {
|
|
233
|
+
process.stdout.write(`= ${e.file} — unchanged\n`);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
if (changed.length === 0) {
|
|
238
|
+
process.stdout.write("\nNo files need changes — already at target version, or no matching patterns found.\n");
|
|
239
|
+
if (!write) return;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
if (!write) {
|
|
243
|
+
process.stdout.write("\nDry run only — no files written. Re-run with --write to apply.\n");
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
for (const e of changed) {
|
|
248
|
+
writeFileSync(join(REPO_ROOT, e.file), e.after, "utf8");
|
|
249
|
+
}
|
|
250
|
+
process.stdout.write(`\nWrote ${changed.length} file(s).\n`);
|
|
251
|
+
|
|
252
|
+
process.stdout.write("\nSyncing lockfile (npm install --package-lock-only)...\n");
|
|
253
|
+
execSync("npm install --package-lock-only", { cwd: REPO_ROOT, stdio: "inherit" });
|
|
254
|
+
|
|
255
|
+
process.stdout.write("\nSelf-verifying with check:versions...\n");
|
|
256
|
+
const { ok, errors, values } = checkVersions();
|
|
257
|
+
process.stdout.write(`version lockstep: ${JSON.stringify(values)}\n`);
|
|
258
|
+
if (!ok) {
|
|
259
|
+
for (const e of errors) process.stderr.write(`::error::${e}\n`);
|
|
260
|
+
process.stderr.write("::error::bump-version: check:versions failed after writing — see errors above.\n");
|
|
261
|
+
process.exitCode = 1;
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
process.stdout.write("✓ all version strings are aligned\n");
|
|
265
|
+
|
|
266
|
+
process.stdout.write(
|
|
267
|
+
"\nReminder — these are MANUAL, not done by this script:\n" +
|
|
268
|
+
` - Move CHANGELOG.md's [Unreleased] section to "## [${version}] — <DATE>".\n` +
|
|
269
|
+
` - Add a new SKILL.md release-note bullet: "- **${version}:** …".\n`,
|
|
270
|
+
);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// Run only when invoked directly (so a test can import the pure functions without side effects).
|
|
274
|
+
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) main();
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// Compares the current structured surface (schema/*.json, action.yml IO, documented COWORK_* env
|
|
2
|
+
// vars — see scripts/lib/surface.ts) against the committed test/fixtures/surface-baseline.json and
|
|
3
|
+
// categorizes the diff into additions / removals / changes.
|
|
4
|
+
//
|
|
5
|
+
// npx tsx scripts/check-surface.ts
|
|
6
|
+
//
|
|
7
|
+
// Pre-1.0, drift detection lives in test/surface-contract.test.ts as a plain snapshot-sync assertion
|
|
8
|
+
// — ANY diff (including a pure addition) fails that test, forcing a conscious `npm run gen:surface`
|
|
9
|
+
// regen + review before it ships. This script/module is the FUTURE 1.0 upgrade path: at 1.0, switch
|
|
10
|
+
// the test to call checkSurface() and hard-fail only on `removed`/`changed` — a pure `added` result
|
|
11
|
+
// is fine without a major bump, since additions aren't a compatibility break.
|
|
12
|
+
import { readFileSync } from "node:fs";
|
|
13
|
+
import { dirname, join } from "node:path";
|
|
14
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
15
|
+
import { computeSurface } from "./lib/surface.js";
|
|
16
|
+
|
|
17
|
+
const REPO_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
|
|
18
|
+
const BASELINE_PATH = join(REPO_ROOT, "test/fixtures/surface-baseline.json");
|
|
19
|
+
|
|
20
|
+
export interface SurfaceDiff {
|
|
21
|
+
ok: boolean;
|
|
22
|
+
added: string[];
|
|
23
|
+
removed: string[];
|
|
24
|
+
changed: string[];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Flatten an arbitrarily-nested JSON-able value into dotted/bracketed leaf paths -> a stable string
|
|
28
|
+
* value, so two surfaces can be diffed key-by-key regardless of nesting shape. */
|
|
29
|
+
function flatten(value: unknown, prefix: string, out: Map<string, string>): void {
|
|
30
|
+
if (value === null || typeof value !== "object") {
|
|
31
|
+
out.set(prefix, JSON.stringify(value));
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
if (Array.isArray(value)) {
|
|
35
|
+
if (value.length === 0) {
|
|
36
|
+
out.set(prefix, "[]");
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
value.forEach((item, i) => flatten(item, `${prefix}[${i}]`, out));
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
const obj = value as Record<string, unknown>;
|
|
43
|
+
const keys = Object.keys(obj);
|
|
44
|
+
if (keys.length === 0) {
|
|
45
|
+
out.set(prefix, "{}");
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
for (const key of keys) flatten(obj[key], prefix ? `${prefix}.${key}` : key, out);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Compare computeSurface() against the committed baseline. `added` (a new leaf path) is fine at
|
|
52
|
+
* 1.0; `removed` and `changed` are breaking and, at 1.0, must gate a release without a major bump. */
|
|
53
|
+
export function checkSurface(): SurfaceDiff {
|
|
54
|
+
const baseline = JSON.parse(readFileSync(BASELINE_PATH, "utf8")) as unknown;
|
|
55
|
+
const current = computeSurface() as unknown;
|
|
56
|
+
|
|
57
|
+
const baseFlat = new Map<string, string>();
|
|
58
|
+
const curFlat = new Map<string, string>();
|
|
59
|
+
flatten(baseline, "", baseFlat);
|
|
60
|
+
flatten(current, "", curFlat);
|
|
61
|
+
|
|
62
|
+
const added: string[] = [];
|
|
63
|
+
const removed: string[] = [];
|
|
64
|
+
const changed: string[] = [];
|
|
65
|
+
|
|
66
|
+
for (const [path, curVal] of curFlat) {
|
|
67
|
+
if (!baseFlat.has(path)) added.push(path);
|
|
68
|
+
else if (baseFlat.get(path) !== curVal) changed.push(path);
|
|
69
|
+
}
|
|
70
|
+
for (const path of baseFlat.keys()) {
|
|
71
|
+
if (!curFlat.has(path)) removed.push(path);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
added.sort();
|
|
75
|
+
removed.sort();
|
|
76
|
+
changed.sort();
|
|
77
|
+
|
|
78
|
+
return { ok: removed.length === 0 && changed.length === 0, added, removed, changed };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function main(): void {
|
|
82
|
+
const { ok, added, removed, changed } = checkSurface();
|
|
83
|
+
process.stdout.write(`surface diff: +${added.length} -${removed.length} ~${changed.length}\n`);
|
|
84
|
+
if (added.length) process.stdout.write(` added: ${added.join(", ")}\n`);
|
|
85
|
+
if (removed.length) process.stderr.write(`::error::removed: ${removed.join(", ")}\n`);
|
|
86
|
+
if (changed.length) process.stderr.write(`::error::changed: ${changed.join(", ")}\n`);
|
|
87
|
+
if (ok) {
|
|
88
|
+
process.stdout.write("✓ no breaking surface changes\n");
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
process.exitCode = 1;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Run only when invoked directly (so a test can import checkSurface without side effects).
|
|
95
|
+
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) main();
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// Regenerates the committed structured-surface snapshot.
|
|
2
|
+
//
|
|
3
|
+
// npm run gen:surface
|
|
4
|
+
//
|
|
5
|
+
// Writes test/fixtures/surface-baseline.json from the CURRENT repo state: schema/*.json field
|
|
6
|
+
// paths/enums, action.yml's inputs + outputs, and the documented COWORK_* env-var set (see
|
|
7
|
+
// scripts/lib/surface.ts for exactly what's covered and what's deliberately not).
|
|
8
|
+
//
|
|
9
|
+
// Run this whenever one of those surfaces changes intentionally, then review the diff — especially
|
|
10
|
+
// any removal or type/enum change, which pre-1.0 is still allowed to ship but must be a conscious
|
|
11
|
+
// decision, not silent drift. test/surface-contract.test.ts fails until the snapshot is regenerated
|
|
12
|
+
// to match the live surface.
|
|
13
|
+
import { writeFileSync } from "node:fs";
|
|
14
|
+
import { dirname, join } from "node:path";
|
|
15
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
16
|
+
import { computeSurface } from "./lib/surface.js";
|
|
17
|
+
|
|
18
|
+
const REPO_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
|
|
19
|
+
export const BASELINE_PATH = join(REPO_ROOT, "test/fixtures/surface-baseline.json");
|
|
20
|
+
|
|
21
|
+
function main(): void {
|
|
22
|
+
const surface = computeSurface();
|
|
23
|
+
writeFileSync(BASELINE_PATH, JSON.stringify(surface, null, 2) + "\n");
|
|
24
|
+
process.stdout.write("wrote test/fixtures/surface-baseline.json\n");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Run only when invoked directly (so a test can import computeSurface/BASELINE_PATH without side effects).
|
|
28
|
+
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) main();
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// Scrapes every COWORK_* env-var name referenced anywhere in src/, across all three shapes the
|
|
2
|
+
// codebase actually reads env vars in:
|
|
3
|
+
// 1. `process.env.COWORK_X` dot-access
|
|
4
|
+
// 2. a quoted literal (helper-read families like envPositiveNumber("COWORK_X"), or an env-name
|
|
5
|
+
// constant assigned once and dot-accessed elsewhere)
|
|
6
|
+
// 3. `env.COWORK_X` — a destructured/aliased env object read
|
|
7
|
+
// Dot-access alone misses (2) and (3) entirely, so all three patterns are required for a complete
|
|
8
|
+
// scrape. Extracted from test/docs-index-sync.test.ts (its original home) so the documented-env-var
|
|
9
|
+
// surface can be computed once and shared by that anti-drift test and scripts/lib/surface.ts's
|
|
10
|
+
// structured-surface snapshot, instead of drifting as two copies.
|
|
11
|
+
import { readFileSync, readdirSync } from "node:fs";
|
|
12
|
+
import { dirname, join } from "node:path";
|
|
13
|
+
import { fileURLToPath } from "node:url";
|
|
14
|
+
|
|
15
|
+
const REPO_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
|
|
16
|
+
|
|
17
|
+
function tsFilesUnder(dir: string): string[] {
|
|
18
|
+
return readdirSync(dir, { recursive: true, encoding: "utf8" })
|
|
19
|
+
.filter((f) => f.endsWith(".ts"))
|
|
20
|
+
.map((f) => join(dir, f));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const ENV_SCRAPE_PATTERNS = [/process\.env\.(COWORK[A-Z0-9_]+)/g, /["'](COWORK[A-Z0-9_]+)["']/g, /\benv\.(COWORK[A-Z0-9_]+)/g];
|
|
24
|
+
|
|
25
|
+
/** Every COWORK_* env-var name read anywhere under src/, unioned across all three read shapes. */
|
|
26
|
+
export function scrapeCoworkEnvVars(): Set<string> {
|
|
27
|
+
const srcText = tsFilesUnder(join(REPO_ROOT, "src"))
|
|
28
|
+
.map((f) => readFileSync(f, "utf8"))
|
|
29
|
+
.join("\n");
|
|
30
|
+
const names = new Set<string>();
|
|
31
|
+
for (const re of ENV_SCRAPE_PATTERNS) {
|
|
32
|
+
for (const m of srcText.matchAll(re)) names.add(m[1]);
|
|
33
|
+
}
|
|
34
|
+
return names;
|
|
35
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
// Computes a deterministic snapshot of the harness's structured, machine-checkable public surfaces:
|
|
2
|
+
// the JSON Schemas under schema/*.json (property paths + enum/const values, including the exit-code
|
|
3
|
+
// enums), action.yml's inputs + outputs, and the documented COWORK_* env-var set.
|
|
4
|
+
//
|
|
5
|
+
// Read by scripts/gen-surface.ts (writes the committed baseline) and scripts/check-surface.ts /
|
|
6
|
+
// test/surface-contract.test.ts (compare the live repo against it).
|
|
7
|
+
//
|
|
8
|
+
// This is deliberately a PARTIAL surface. Not covered (stays a manual release-checklist item):
|
|
9
|
+
// - the CLI command/flag surface — no machine-readable source exists; cli-structural-guard drives
|
|
10
|
+
// a hand-maintained CASES list and cli-help greps pinned strings, and --help text is explicitly
|
|
11
|
+
// non-contractual.
|
|
12
|
+
// - per-command exit-code SEMANTICS — the exit-code *values* are schema-expressible (and ARE
|
|
13
|
+
// covered, e.g. verdict.exitCode / verify-cassettes' ok/coverage shape) but their meaning isn't.
|
|
14
|
+
// - the PlatformBaseline shape — Zod-only in src/types.ts, no schema/*.json emitted for it.
|
|
15
|
+
import { readFileSync, readdirSync } from "node:fs";
|
|
16
|
+
import { dirname, join } from "node:path";
|
|
17
|
+
import { fileURLToPath } from "node:url";
|
|
18
|
+
import { parse as parseYaml } from "yaml";
|
|
19
|
+
import { scrapeCoworkEnvVars } from "./env-scrape.js";
|
|
20
|
+
|
|
21
|
+
export { scrapeCoworkEnvVars } from "./env-scrape.js";
|
|
22
|
+
|
|
23
|
+
const REPO_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
|
|
24
|
+
const SCHEMA_DIR = join(REPO_ROOT, "schema");
|
|
25
|
+
|
|
26
|
+
interface SchemaLeaf {
|
|
27
|
+
type?: unknown;
|
|
28
|
+
enum?: unknown[];
|
|
29
|
+
const?: unknown;
|
|
30
|
+
ref?: string;
|
|
31
|
+
required?: true;
|
|
32
|
+
/** additionalProperties === false at this path — narrowing this later (adding it) is breaking. */
|
|
33
|
+
closed?: true;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Walk one JSON Schema node, recording a leaf at `path` for anything meaningful (type/enum/const/
|
|
37
|
+
* $ref/closed/required), then recursing into properties, array items, additionalProperties' value
|
|
38
|
+
* schema, and oneOf/anyOf/allOf branches. `$ref` targets are recorded as a pointer string, not
|
|
39
|
+
* inlined — the referenced definition is walked separately as its own `definitions.<Name>` root, so
|
|
40
|
+
* nothing is lost and there's no risk of infinite recursion on a self-referential schema. */
|
|
41
|
+
function walkSchemaNode(node: unknown, path: string, requiredHere: boolean, out: Map<string, SchemaLeaf>): void {
|
|
42
|
+
if (node === null || typeof node !== "object" || Array.isArray(node)) return;
|
|
43
|
+
const n = node as Record<string, unknown>;
|
|
44
|
+
|
|
45
|
+
const leaf: SchemaLeaf = {};
|
|
46
|
+
if ("type" in n) leaf.type = n.type;
|
|
47
|
+
if (Array.isArray(n.enum)) leaf.enum = n.enum;
|
|
48
|
+
if ("const" in n) leaf.const = n.const;
|
|
49
|
+
if (typeof n.$ref === "string") leaf.ref = n.$ref;
|
|
50
|
+
if (n.additionalProperties === false) leaf.closed = true;
|
|
51
|
+
if (requiredHere) leaf.required = true;
|
|
52
|
+
if (Object.keys(leaf).length > 0) {
|
|
53
|
+
out.set(path, { ...(out.get(path) ?? {}), ...leaf });
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const requiredSet = new Set(Array.isArray(n.required) ? (n.required as string[]) : []);
|
|
57
|
+
const props = n.properties;
|
|
58
|
+
if (props && typeof props === "object" && !Array.isArray(props)) {
|
|
59
|
+
for (const key of Object.keys(props as Record<string, unknown>).sort()) {
|
|
60
|
+
walkSchemaNode((props as Record<string, unknown>)[key], `${path}.${key}`, requiredSet.has(key), out);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
if ("items" in n) {
|
|
64
|
+
const items = n.items;
|
|
65
|
+
if (Array.isArray(items)) {
|
|
66
|
+
items.forEach((item, i) => walkSchemaNode(item, `${path}[${i}]`, false, out));
|
|
67
|
+
} else {
|
|
68
|
+
walkSchemaNode(items, `${path}[]`, false, out);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
if (n.additionalProperties && typeof n.additionalProperties === "object") {
|
|
72
|
+
walkSchemaNode(n.additionalProperties, `${path}{}`, false, out);
|
|
73
|
+
}
|
|
74
|
+
for (const kind of ["oneOf", "anyOf", "allOf"] as const) {
|
|
75
|
+
const branches = n[kind];
|
|
76
|
+
if (Array.isArray(branches)) {
|
|
77
|
+
branches.forEach((sub, i) => walkSchemaNode(sub, `${path}<${kind}:${i}>`, false, out));
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Property-path surface of one JSON Schema document (root paths, plus each `definitions`/`$defs`
|
|
83
|
+
* entry as its own `definitions.<Name>` root), sorted by path for a stable diff. */
|
|
84
|
+
export function surfaceForSchemaDoc(doc: Record<string, unknown>): Record<string, SchemaLeaf> {
|
|
85
|
+
const out = new Map<string, SchemaLeaf>();
|
|
86
|
+
walkSchemaNode(doc, "", false, out);
|
|
87
|
+
const defs = (doc.definitions ?? doc.$defs) as Record<string, unknown> | undefined;
|
|
88
|
+
if (defs && typeof defs === "object") {
|
|
89
|
+
for (const name of Object.keys(defs).sort()) {
|
|
90
|
+
walkSchemaNode(defs[name], `definitions.${name}`, false, out);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
const sorted = [...out.entries()].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
|
|
94
|
+
return Object.fromEntries(sorted);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Every schema/*.json file, each reduced to its property-path/enum surface. */
|
|
98
|
+
export function computeSchemaSurface(): Record<string, Record<string, SchemaLeaf>> {
|
|
99
|
+
const files = readdirSync(SCHEMA_DIR)
|
|
100
|
+
.filter((f) => f.endsWith(".json"))
|
|
101
|
+
.sort();
|
|
102
|
+
const out: Record<string, Record<string, SchemaLeaf>> = {};
|
|
103
|
+
for (const f of files) {
|
|
104
|
+
const doc = JSON.parse(readFileSync(join(SCHEMA_DIR, f), "utf8")) as Record<string, unknown>;
|
|
105
|
+
out[f] = surfaceForSchemaDoc(doc);
|
|
106
|
+
}
|
|
107
|
+
return out;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
interface ActionInputSurface {
|
|
111
|
+
required: boolean;
|
|
112
|
+
/** `null` when the input has no `default:` key at all (distinct from an explicit empty default). */
|
|
113
|
+
default: string | null;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
interface ActionSurface {
|
|
117
|
+
inputs: Record<string, ActionInputSurface>;
|
|
118
|
+
outputs: string[];
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** action.yml's caller-facing contract: input names + required/default, and output names. Input/
|
|
122
|
+
* output `description:` prose and each output's internal `value:` step-output expression are
|
|
123
|
+
* excluded on purpose — they're not part of the caller-facing surface (rewording a description, or
|
|
124
|
+
* renaming the internal step id an output reads from, isn't a breaking change for a consumer). */
|
|
125
|
+
export function computeActionSurface(): ActionSurface {
|
|
126
|
+
const doc = parseYaml(readFileSync(join(REPO_ROOT, "action.yml"), "utf8")) as {
|
|
127
|
+
inputs?: Record<string, { required?: boolean; default?: unknown }>;
|
|
128
|
+
outputs?: Record<string, unknown>;
|
|
129
|
+
};
|
|
130
|
+
const inputs: Record<string, ActionInputSurface> = {};
|
|
131
|
+
for (const name of Object.keys(doc.inputs ?? {}).sort()) {
|
|
132
|
+
const spec = doc.inputs![name];
|
|
133
|
+
inputs[name] = {
|
|
134
|
+
required: spec.required === true,
|
|
135
|
+
default: "default" in spec ? String(spec.default) : null,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
const outputs = Object.keys(doc.outputs ?? {}).sort();
|
|
139
|
+
return { inputs, outputs };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export interface Surface {
|
|
143
|
+
schemas: Record<string, Record<string, SchemaLeaf>>;
|
|
144
|
+
action: ActionSurface;
|
|
145
|
+
env: { coworkVars: string[] };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** The full v1 structured-surface snapshot: schema/*.json field paths + enums, action.yml IO, and
|
|
149
|
+
* the documented COWORK_* env-var set. Pure and deterministic — file reads only, sorted keys, no
|
|
150
|
+
* timestamps or unsorted Set/Map iteration reaching the output. */
|
|
151
|
+
export function computeSurface(): Surface {
|
|
152
|
+
return {
|
|
153
|
+
schemas: computeSchemaSurface(),
|
|
154
|
+
action: computeActionSurface(),
|
|
155
|
+
env: { coworkVars: [...scrapeCoworkEnvVars()].sort() },
|
|
156
|
+
};
|
|
157
|
+
}
|
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
// Run the checks that today only fail AFTER a tag is pushed — locally, before the tag exists.
|
|
2
|
+
// All checks are read-only (no writes, no push, no publish).
|
|
3
|
+
//
|
|
4
|
+
// npx tsx scripts/release-preflight.ts # pre-flight for the release branch/PR
|
|
5
|
+
// npx tsx scripts/release-preflight.ts --for-tag # ALSO run the tag-time HEAD/CI check (hard fail)
|
|
6
|
+
//
|
|
7
|
+
// Checks 1-4 mirror (and check 2 tightens) the gates release.yml enforces after the tag push:
|
|
8
|
+
// 1. check:versions passes (scripts/check-versions.ts).
|
|
9
|
+
// 2. CHANGELOG.md has a "## [<package.json version>]" heading (release.yml:54-59's regex) AND —
|
|
10
|
+
// stricter than release.yml — the section body is non-empty.
|
|
11
|
+
// 3. The tag `v<version>` does not already exist, locally or on origin.
|
|
12
|
+
// 4. The working tree is clean (`git status --porcelain` empty).
|
|
13
|
+
// 5. Best-effort, WARN-only: nudge about the ANTHROPIC_API_KEY repo secret (live-suite gate).
|
|
14
|
+
// 6. --for-tag only, HARD fail: HEAD == origin/main HEAD, and a successful push-event ci.yml run
|
|
15
|
+
// exists for HEAD — the exact check that would have caught the 0.33.0 mis-tag (tagging a
|
|
16
|
+
// release-branch head instead of the merge commit).
|
|
17
|
+
//
|
|
18
|
+
// See docs/internal/2026-07-13-release-process-improvements-plan.md (P4 / P4b) for the design.
|
|
19
|
+
|
|
20
|
+
import { spawnSync } from "node:child_process";
|
|
21
|
+
import { readFileSync } from "node:fs";
|
|
22
|
+
import { dirname, join } from "node:path";
|
|
23
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
24
|
+
import { checkVersions } from "./check-versions.js";
|
|
25
|
+
|
|
26
|
+
const REPO_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
|
|
27
|
+
const r = (p: string) => readFileSync(join(REPO_ROOT, p), "utf8");
|
|
28
|
+
const json = (p: string) => JSON.parse(r(p)) as Record<string, any>;
|
|
29
|
+
|
|
30
|
+
const SEMVER = /^\d+\.\d+\.\d+$/;
|
|
31
|
+
|
|
32
|
+
/** Same shape as check-versions.ts's SEMVER check — kept local so this file has no side-effecting import. */
|
|
33
|
+
export function isValidSemver(v: string): boolean {
|
|
34
|
+
return SEMVER.test(v);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Mirrors release.yml's "Verify CHANGELOG has a heading for this version" step (a literal
|
|
39
|
+
* `^## \[<version>\]` match, dots escaped) — plus a stricter, preflight-only requirement that the
|
|
40
|
+
* section body (everything between this heading and the next `## [`) is non-empty. release.yml itself
|
|
41
|
+
* does NOT require a non-empty body (its release-notes extraction step has its own fallback for that);
|
|
42
|
+
* this function is intentionally stricter so an accidentally-empty section is caught before the tag.
|
|
43
|
+
*/
|
|
44
|
+
export function changelogHasVersionSection(changelogText: string, version: string): boolean {
|
|
45
|
+
const escaped = version.replace(/\./g, "\\.");
|
|
46
|
+
const headingRe = new RegExp(`^## \\[${escaped}\\]`, "m");
|
|
47
|
+
if (!headingRe.test(changelogText)) return false;
|
|
48
|
+
|
|
49
|
+
const headingPrefix = `## [${version}]`;
|
|
50
|
+
const lines = changelogText.split("\n");
|
|
51
|
+
let inSection = false;
|
|
52
|
+
const body: string[] = [];
|
|
53
|
+
for (const line of lines) {
|
|
54
|
+
if (!inSection) {
|
|
55
|
+
if (line.startsWith(headingPrefix)) inSection = true;
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
if (line.startsWith("## [")) break;
|
|
59
|
+
body.push(line);
|
|
60
|
+
}
|
|
61
|
+
return body.some((line) => line.trim().length > 0);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Whether tag `v<version>` already exists, either locally (`git tag -l` output, one tag per entry) or
|
|
66
|
+
* on origin (`git ls-remote --tags origin` output, raw lines of the form `<sha>\trefs/tags/vX.Y.Z`,
|
|
67
|
+
* possibly with a trailing `^{}` for the dereferenced annotated-tag entry).
|
|
68
|
+
*/
|
|
69
|
+
export function tagExists(localTags: string[], remoteRefLines: string[], version: string): boolean {
|
|
70
|
+
const tagName = `v${version}`;
|
|
71
|
+
if (localTags.includes(tagName)) return true;
|
|
72
|
+
const suffixRe = new RegExp(`refs/tags/${tagName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(\\^\\{\\})?$`);
|
|
73
|
+
return remoteRefLines.some((line) => suffixRe.test(line.trim()));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
type CheckStatus = "PASS" | "FAIL" | "WARN" | "SKIP";
|
|
77
|
+
interface CheckResult {
|
|
78
|
+
name: string;
|
|
79
|
+
status: CheckStatus;
|
|
80
|
+
detail: string;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function run(cmd: string, args: string[]): { ok: boolean; status: number | null; stdout: string; stderr: string } {
|
|
84
|
+
const res = spawnSync(cmd, args, { encoding: "utf8", cwd: REPO_ROOT });
|
|
85
|
+
if (res.error) return { ok: false, status: null, stdout: "", stderr: String(res.error.message ?? res.error) };
|
|
86
|
+
return { ok: res.status === 0, status: res.status, stdout: res.stdout ?? "", stderr: res.stderr ?? "" };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function checkCheckVersions(): CheckResult {
|
|
90
|
+
const { ok, errors, values } = checkVersions();
|
|
91
|
+
return {
|
|
92
|
+
name: "check:versions",
|
|
93
|
+
status: ok ? "PASS" : "FAIL",
|
|
94
|
+
detail: ok ? `version lockstep OK (package.json=${values.pkg})` : errors.join("; "),
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function checkChangelog(version: string): CheckResult {
|
|
99
|
+
const text = r("CHANGELOG.md");
|
|
100
|
+
const ok = changelogHasVersionSection(text, version);
|
|
101
|
+
return {
|
|
102
|
+
name: "CHANGELOG.md heading + non-empty section",
|
|
103
|
+
status: ok ? "PASS" : "FAIL",
|
|
104
|
+
detail: ok ? `found non-empty "## [${version}]" section` : `missing, or empty, "## [${version}]" section in CHANGELOG.md`,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function checkTagDoesNotExist(version: string): CheckResult {
|
|
109
|
+
const local = run("git", ["tag", "-l"]);
|
|
110
|
+
const localTags = local.stdout.split("\n").filter((l) => l.trim().length > 0);
|
|
111
|
+
const remote = run("git", ["ls-remote", "--tags", "origin"]);
|
|
112
|
+
if (!remote.ok) {
|
|
113
|
+
return {
|
|
114
|
+
name: `tag v${version} does not already exist`,
|
|
115
|
+
status: "FAIL",
|
|
116
|
+
detail: `could not query origin tags: ${remote.stderr || remote.stdout}`,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
const remoteLines = remote.stdout.split("\n").filter((l) => l.trim().length > 0);
|
|
120
|
+
const exists = tagExists(localTags, remoteLines, version);
|
|
121
|
+
return {
|
|
122
|
+
name: `tag v${version} does not already exist`,
|
|
123
|
+
status: exists ? "FAIL" : "PASS",
|
|
124
|
+
detail: exists ? `v${version} already exists (local and/or origin)` : `v${version} is unused`,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function checkWorkingTreeClean(): CheckResult {
|
|
129
|
+
const status = run("git", ["status", "--porcelain"]);
|
|
130
|
+
const clean = status.ok && status.stdout.trim().length === 0;
|
|
131
|
+
return {
|
|
132
|
+
name: "working tree clean",
|
|
133
|
+
status: clean ? "PASS" : "FAIL",
|
|
134
|
+
detail: clean ? "no uncommitted changes" : `uncommitted changes present:\n${status.stdout}`,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Best-effort, WARN-only. `gh secret list` sees ONLY repo-level Actions secrets — an org- or
|
|
140
|
+
* environment-level ANTHROPIC_API_KEY would false-warn here, and the call itself needs admin scope on
|
|
141
|
+
* the repo. If `gh` is unavailable or unauthenticated, print the reminder unconditionally rather than
|
|
142
|
+
* silently skipping — RELEASING.md §9 documents the SKIP_LIVE_SCENARIOS override.
|
|
143
|
+
*/
|
|
144
|
+
function checkLiveSuiteKeyReminder(): CheckResult {
|
|
145
|
+
const REMINDER =
|
|
146
|
+
"if ANTHROPIC_API_KEY is not set as a repo secret, push-to-main ci will hard-fail the live scenario " +
|
|
147
|
+
"suite — set the secret or plan the SKIP_LIVE_SCENARIOS override (RELEASING.md §9). Note: `gh secret " +
|
|
148
|
+
"list` only sees repo-level Actions secrets — an org/environment secret would false-warn here, and the " +
|
|
149
|
+
"call needs admin scope.";
|
|
150
|
+
|
|
151
|
+
const ghVersion = run("gh", ["--version"]);
|
|
152
|
+
if (!ghVersion.ok) {
|
|
153
|
+
return { name: "live-suite key reminder", status: "WARN", detail: `gh not available — ${REMINDER}` };
|
|
154
|
+
}
|
|
155
|
+
const secrets = run("gh", ["secret", "list"]);
|
|
156
|
+
if (!secrets.ok) {
|
|
157
|
+
return {
|
|
158
|
+
name: "live-suite key reminder",
|
|
159
|
+
status: "WARN",
|
|
160
|
+
detail: `gh secret list failed (unauthenticated or insufficient scope) — ${REMINDER}`,
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
const hasKey = /^ANTHROPIC_API_KEY\b/m.test(secrets.stdout);
|
|
164
|
+
return {
|
|
165
|
+
name: "live-suite key reminder",
|
|
166
|
+
status: hasKey ? "PASS" : "WARN",
|
|
167
|
+
detail: hasKey ? "ANTHROPIC_API_KEY found among repo secrets" : `no repo-level ANTHROPIC_API_KEY — ${REMINDER}`,
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* [P4b] The check that directly prevents the 0.33.0 mis-tag: tagging a release-branch/PR head instead
|
|
173
|
+
* of the merge commit. Only meaningful right before the tag push, so it is gated behind --for-tag and,
|
|
174
|
+
* unlike checks 1-4, is a HARD failure there (tagging is near-irreversible).
|
|
175
|
+
*/
|
|
176
|
+
function checkForTag(): CheckResult {
|
|
177
|
+
const fetch = run("git", ["fetch", "origin", "main"]);
|
|
178
|
+
if (!fetch.ok) {
|
|
179
|
+
return {
|
|
180
|
+
name: "HEAD == origin/main, with a green push-event ci run",
|
|
181
|
+
status: "FAIL",
|
|
182
|
+
detail: `git fetch origin main failed: ${fetch.stderr || fetch.stdout}`,
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
const head = run("git", ["rev-parse", "HEAD"]);
|
|
186
|
+
const originMain = run("git", ["rev-parse", "origin/main"]);
|
|
187
|
+
if (!head.ok || !originMain.ok) {
|
|
188
|
+
return {
|
|
189
|
+
name: "HEAD == origin/main, with a green push-event ci run",
|
|
190
|
+
status: "FAIL",
|
|
191
|
+
detail: "could not resolve HEAD or origin/main",
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
const headSha = head.stdout.trim();
|
|
195
|
+
const originSha = originMain.stdout.trim();
|
|
196
|
+
if (headSha !== originSha) {
|
|
197
|
+
return {
|
|
198
|
+
name: "HEAD == origin/main, with a green push-event ci run",
|
|
199
|
+
status: "FAIL",
|
|
200
|
+
detail:
|
|
201
|
+
`HEAD (${headSha}) != origin/main (${originSha}) — you are about to tag a branch/PR head, not the ` +
|
|
202
|
+
`merge commit. Merge to main first (gh pr merge <n> --merge), then git checkout main && git pull.`,
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const ghVersion = run("gh", ["--version"]);
|
|
207
|
+
if (!ghVersion.ok) {
|
|
208
|
+
return {
|
|
209
|
+
name: "HEAD == origin/main, with a green push-event ci run",
|
|
210
|
+
status: "FAIL",
|
|
211
|
+
detail: "HEAD == origin/main, but `gh` is unavailable — cannot verify a push-event ci.yml run for HEAD",
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const runList = run("gh", [
|
|
216
|
+
"run",
|
|
217
|
+
"list",
|
|
218
|
+
"--workflow=ci.yml",
|
|
219
|
+
`--commit=${headSha}`,
|
|
220
|
+
"--event",
|
|
221
|
+
"push",
|
|
222
|
+
"-L1",
|
|
223
|
+
"--json",
|
|
224
|
+
"status,conclusion",
|
|
225
|
+
]);
|
|
226
|
+
if (!runList.ok) {
|
|
227
|
+
return {
|
|
228
|
+
name: "HEAD == origin/main, with a green push-event ci run",
|
|
229
|
+
status: "FAIL",
|
|
230
|
+
detail: `gh run list failed: ${runList.stderr || runList.stdout}`,
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
let runs: Array<{ status: string; conclusion: string | null }>;
|
|
234
|
+
try {
|
|
235
|
+
runs = JSON.parse(runList.stdout);
|
|
236
|
+
} catch {
|
|
237
|
+
return {
|
|
238
|
+
name: "HEAD == origin/main, with a green push-event ci run",
|
|
239
|
+
status: "FAIL",
|
|
240
|
+
detail: `could not parse gh run list output: ${runList.stdout}`,
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
if (runs.length === 0) {
|
|
244
|
+
return {
|
|
245
|
+
name: "HEAD == origin/main, with a green push-event ci run",
|
|
246
|
+
status: "FAIL",
|
|
247
|
+
detail: `no push-event ci.yml run found for HEAD (${headSha}) — full 40-char SHA required, a short SHA returns empty`,
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
const [run0] = runs;
|
|
251
|
+
const ok = run0.status === "completed" && run0.conclusion === "success";
|
|
252
|
+
return {
|
|
253
|
+
name: "HEAD == origin/main, with a green push-event ci run",
|
|
254
|
+
status: ok ? "PASS" : "FAIL",
|
|
255
|
+
detail: ok
|
|
256
|
+
? `HEAD == origin/main (${headSha}), push-event ci.yml: completed/success`
|
|
257
|
+
: `push-event ci.yml for HEAD (${headSha}) is status=${run0.status} conclusion=${run0.conclusion}`,
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function printResult(res: CheckResult): void {
|
|
262
|
+
const icon = res.status === "PASS" ? "✓" : res.status === "WARN" ? "⚠" : res.status === "SKIP" ? "–" : "✗";
|
|
263
|
+
process.stdout.write(`${icon} [${res.status}] ${res.name}\n`);
|
|
264
|
+
if (res.status !== "PASS") process.stdout.write(` ${res.detail.replace(/\n/g, "\n ")}\n`);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function main(): void {
|
|
268
|
+
const forTag = process.argv.includes("--for-tag");
|
|
269
|
+
const pkg = json("package.json");
|
|
270
|
+
const version = pkg.version as string;
|
|
271
|
+
if (!isValidSemver(version)) {
|
|
272
|
+
process.stderr.write(`::error::package.json version "${version}" is not X.Y.Z\n`);
|
|
273
|
+
process.exitCode = 1;
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
process.stdout.write(`release preflight — version ${version}${forTag ? " (--for-tag)" : ""}\n\n`);
|
|
278
|
+
|
|
279
|
+
const hardResults: CheckResult[] = [
|
|
280
|
+
checkCheckVersions(),
|
|
281
|
+
checkChangelog(version),
|
|
282
|
+
checkTagDoesNotExist(version),
|
|
283
|
+
checkWorkingTreeClean(),
|
|
284
|
+
];
|
|
285
|
+
const warnResult = checkLiveSuiteKeyReminder();
|
|
286
|
+
|
|
287
|
+
for (const res of hardResults) printResult(res);
|
|
288
|
+
printResult(warnResult);
|
|
289
|
+
|
|
290
|
+
let forTagResult: CheckResult | undefined;
|
|
291
|
+
if (forTag) {
|
|
292
|
+
forTagResult = checkForTag();
|
|
293
|
+
printResult(forTagResult);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const hardFailed = hardResults.some((r) => r.status === "FAIL") || forTagResult?.status === "FAIL";
|
|
297
|
+
|
|
298
|
+
process.stdout.write("\n");
|
|
299
|
+
if (hardFailed) {
|
|
300
|
+
process.stdout.write("✗ release preflight FAILED — fix the above before tagging.\n");
|
|
301
|
+
process.exitCode = 1;
|
|
302
|
+
} else {
|
|
303
|
+
process.stdout.write("✓ release preflight PASSED.\n");
|
|
304
|
+
if (forTag) {
|
|
305
|
+
process.stdout.write(`\nNext:\n git tag v${version}\n git push origin v${version}\n`);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// Run only when invoked directly (so a test can import the pure helpers without side effects).
|
|
311
|
+
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) main();
|