|
| 1 | +--- |
| 2 | +name: hookdeck-cli-review |
| 3 | +description: >- |
| 4 | + Reviews changes to the Hookdeck CLI: why a green pull request still needs its |
| 5 | + acceptance run read, where an acceptance-test build tag must be registered, |
| 6 | + which generated files and wire contracts break silently, which docs move with |
| 7 | + the code, and what not to raise. Use when reviewing a pull request, a diff or |
| 8 | + a branch in hookdeck-cli, or when deciding whether a change is safe to merge. |
| 9 | +--- |
| 10 | + |
| 11 | +# Reviewing changes to the Hookdeck CLI |
| 12 | + |
| 13 | +`AGENTS.md` is the reference for building, testing and linting this repository, |
| 14 | +and for the rules it already states. Read it first. This file is the review |
| 15 | +delta: what to look at in a diff, and the traps that a green pull request |
| 16 | +hides. Where a rule already lives in `AGENTS.md`, this points at it rather than |
| 17 | +restating it. |
| 18 | + |
| 19 | +## Acceptance runs on the pull request, but nothing enforces it |
| 20 | + |
| 21 | +`.github/workflows/test-acceptance.yml` triggers on every `pull_request` |
| 22 | +targeting `main` or `next`, with no path filter. So the acceptance suite — the |
| 23 | +three matrix slices plus the separate telemetry job — **does run on the pull |
| 24 | +request in front of you**. Assuming it did not is the wrong call. |
| 25 | + |
| 26 | +What it is not is *required*. Branch protection on `main` requires exactly: |
| 27 | + |
| 28 | +``` |
| 29 | +unit-test build-linux build-mac build-windows |
| 30 | +``` |
| 31 | + |
| 32 | +Acceptance is not in that list. A pull request with a **red or skipped** |
| 33 | +acceptance run is still mergeable, and the merge button will not tell you. |
| 34 | + |
| 35 | +That list is the one claim here with no source in the tree — it lives in |
| 36 | +GitHub's branch-protection settings, so it can change without a commit and |
| 37 | +without this file noticing. Re-read it rather than trusting it: |
| 38 | + |
| 39 | +``` |
| 40 | +gh api repos/hookdeck/hookdeck-cli/branches/main/protection \ |
| 41 | + --jq '.required_status_checks.contexts' |
| 42 | +``` |
| 43 | + |
| 44 | +**So read `gh pr checks` and flag a red or skipped acceptance run**, because |
| 45 | +nothing else in the process will. That is the single most useful thing this |
| 46 | +review does that the required checks do not. |
| 47 | + |
| 48 | +### A green acceptance job is not a run |
| 49 | + |
| 50 | +A tick is not coverage either. An acceptance test that needs a credential skips |
| 51 | +itself when the job's `env:` block does not supply one, and a job whose tests |
| 52 | +all skipped still reports green. `gh pr checks` cannot show the difference. |
| 53 | +`acceptance-telemetry` is the live example — see *`CLITelemetry` JSON tags are a |
| 54 | +wire contract* below. |
| 55 | + |
| 56 | +Three gates sit between an assertion existing and a red check, and all three |
| 57 | +have to hold: the file's build tag has to be in a slice list or the telemetry |
| 58 | +job's `-tags`; the test's own guards (`testing.Short()`, a `t.Skip` on a missing |
| 59 | +environment variable) have to pass; and the job's `env:` block has to supply |
| 60 | +what those guards need. |
| 61 | + |
| 62 | +So when a diff touches behaviour an acceptance test covers, read the log rather |
| 63 | +than the colour: |
| 64 | + |
| 65 | +``` |
| 66 | +gh run view --job=<id> --log | grep -E -- '--- (SKIP|PASS): <TestName>' |
| 67 | +``` |
| 68 | + |
| 69 | +### The one case where it genuinely does not run |
| 70 | + |
| 71 | +`pull_request` does not fire when a pull request's head branch is updated by |
| 72 | +**merging another pull request into it**. `test-acceptance.yml` states this in |
| 73 | +a comment above its triggers. A stacked branch — a release branch that collects |
| 74 | +other pull requests — can therefore reach `main` having never run acceptance at |
| 75 | +all, and `gh pr checks` shows that as an absence rather than as a failure. |
| 76 | + |
| 77 | +The remedy is `workflow_dispatch`, which the same workflow accepts against any |
| 78 | +ref. One catch, also in that comment: GitHub reads the workflow definition from |
| 79 | +the ref you dispatch, so a branch can only be dispatched once it already |
| 80 | +carries the `workflow_dispatch` block. A branch cut before that block landed |
| 81 | +has to be rebased first. |
| 82 | + |
| 83 | +Dispatch it on its own, though. `test/acceptance/README.md` § Rate limits |
| 84 | +explains why: a push to the branch also triggers the `pull_request` run, and |
| 85 | +two concurrent runs against the same projects exhaust the API rate limit and |
| 86 | +fail at the timeout with no assertion failures at all — which reads exactly |
| 87 | +like a code failure. |
| 88 | + |
| 89 | +So "acceptance never ran" is a finding with an action attached, not just an |
| 90 | +observation. |
| 91 | + |
| 92 | +### Why a reviewer should care, not just the release engineer |
| 93 | + |
| 94 | +`.github/workflows/release.yml` calls the same acceptance workflow as a gate: |
| 95 | +`build-mac`, `build-linux` and `build-windows` each declare |
| 96 | +`needs: [acceptance]`, and `publish-npm` needs those three builds. A red |
| 97 | +acceptance therefore blocks the whole release pipeline. |
| 98 | + |
| 99 | +Merging a red acceptance does not fail loudly at merge time. It fails at the |
| 100 | +next release tag, on someone else's change, and the bisect starts from the |
| 101 | +wrong commit. |
| 102 | + |
| 103 | +### Running it yourself |
| 104 | + |
| 105 | +`go test ./...` does not include the acceptance suite — see `AGENTS.md` |
| 106 | +§ Running `go test` after code changes. A local run needs real credentials in |
| 107 | +`test/acceptance/.env`, which is gitignored. Without them, "not verified" is the |
| 108 | +honest report, not a pass. |
| 109 | + |
| 110 | +## A new acceptance tag has to be registered in five places |
| 111 | + |
| 112 | +Every `*_test.go` under `test/acceptance/` carries exactly one feature build |
| 113 | +tag, and untagged files leak into every tag build — `AGENTS.md` § Acceptance |
| 114 | +tests and feature tags states that rule; do not repeat it in a review, check it. |
| 115 | + |
| 116 | +`test/acceptance/README.md` § Parallelisation covers the rest of the mechanism: |
| 117 | +which tags are in which slice, that each slice has its own test account, and |
| 118 | +that telemetry runs separately. Read it rather than relying on this file. |
| 119 | + |
| 120 | +What no file states is that the same set of tags is maintained **by hand in |
| 121 | +five places**, and they drift independently: |
| 122 | + |
| 123 | +- `test/acceptance/run_parallel.sh` — `SLICE0_TAGS`, `SLICE1_TAGS`, |
| 124 | + `SLICE2_TAGS`, which is how the suite runs locally |
| 125 | +- `.github/workflows/acceptance.yml` — the `matrix.include` `tags` strings, |
| 126 | + which is how it runs in CI |
| 127 | +- `test/acceptance/README.md` § Running Tests — the "Run all automated tests" |
| 128 | + command, a single `-tags="..."` string that has to carry every tag |
| 129 | +- `test/acceptance/README.md` § Running Tests — the three per-slice commands |
| 130 | +- `test/acceptance/README.md` § Parallelisation — the `Slice N features` bullets |
| 131 | + |
| 132 | +Registered in CI but not in the script, the tag never runs locally. In the |
| 133 | +script but not in CI, it never runs on a pull request. Left out of the "run all" |
| 134 | +command, the documented way to run everything quietly stops running everything — |
| 135 | +and that one is the worst of the five, because the command still exits 0. |
| 136 | + |
| 137 | +All five agree today — but count before reporting drift, because **four of the |
| 138 | +five carry 20 tags, not 21.** `telemetry` is the twenty-first, and it is |
| 139 | +registered *separately* in each of those same files: `run_telemetry()` in |
| 140 | +`run_parallel.sh`, the `acceptance-telemetry` job in `acceptance.yml`, and its |
| 141 | +own `-tags=telemetry` command in the README. Only the "Run all automated tests" |
| 142 | +string carries all 21 in a single list. |
| 143 | + |
| 144 | +So a slice list that reads 20 is correct, not stale. Check all five whenever a |
| 145 | +diff adds a tag — and if the new tag is a telemetry one, check those three |
| 146 | +separate sites instead. |
| 147 | + |
| 148 | +One thing the README gets wrong, before you trust it on this: it attributes the |
| 149 | +matrix to `.github/workflows/test-acceptance.yml`. That file only declares the |
| 150 | +triggers and calls `.github/workflows/acceptance.yml`, which is where the slices |
| 151 | +and their tags actually live. |
| 152 | + |
| 153 | +### The `manual` tag is a deliberate exception |
| 154 | + |
| 155 | +`test/acceptance/project_use_manual_test.go` is `//go:build manual`, and |
| 156 | +`manual` appears in **none** of those five lists. That is intentional: the test |
| 157 | +needs an interactive browser login, which no CI job can perform, and |
| 158 | +`test/acceptance/README.md` gives it its own `-tags=manual` command instead. |
| 159 | +Applying the registration rule literally will report it as drift. It is not. |
| 160 | + |
| 161 | +### Which slice a tag lands in is a decision, not bookkeeping |
| 162 | + |
| 163 | +The README describes the slices; what it does not say is that choosing between |
| 164 | +them is a judgement call. They are not interchangeable buckets: |
| 165 | + |
| 166 | +- a different test account per slice means a test that assumes resources |
| 167 | + created by another slice will not find them |
| 168 | +- slice 0 already carries 12 tags against a 12-minute timeout, while slice 1 |
| 169 | + carries 2 |
| 170 | + |
| 171 | +Adding a slow tag to slice 0 is how the suite starts timing out. If a diff adds |
| 172 | +a tag, the slice it went into is worth a sentence. |
| 173 | + |
| 174 | +The telemetry job is separate for a reason worth checking against a diff: it |
| 175 | +forces `HOOKDECK_CLI_TELEMETRY_DISABLED=0`, where the matrix jobs set it to `1`. |
| 176 | +A telemetry-tagged test added to a slice will not behave as intended. |
| 177 | + |
| 178 | +## Credentials, and logs anyone can read |
| 179 | + |
| 180 | +This repository is public, so **every acceptance log is world-readable**. |
| 181 | +`.github/workflows/acceptance.yml` says so itself, next to |
| 182 | +`HOOKDECK_CLI_TESTING_CLI_KEY`: that key is account-wide, so it has to belong to |
| 183 | +the test-only account rather than to a person — a key that reaches every org its |
| 184 | +owner belongs to must never be one a failing test can print. The per-slice |
| 185 | +`HOOKDECK_CLI_TESTING_API_KEY*` secrets are project-scoped, but they go to the |
| 186 | +same public log. |
| 187 | + |
| 188 | +The question worth asking of a diff is therefore: **does this print or commit a |
| 189 | +credential?** |
| 190 | + |
| 191 | +- a new `t.Logf`, `fmt.Print` or error string that interpolates a key, a config |
| 192 | + file or a whole HTTP request |
| 193 | +- a test fixture or `.env`-shaped file added to the tree |
| 194 | +- a redaction helper whose pattern is narrower than the data it guards |
| 195 | + |
| 196 | +That last one has a concrete edge here. **The CLI writes single-quoted TOML.** |
| 197 | +Viper's writer emits: |
| 198 | + |
| 199 | +```toml |
| 200 | +profile = 'default' |
| 201 | + |
| 202 | +[default] |
| 203 | + api_key = 'cli_secret_abc123' |
| 204 | +``` |
| 205 | + |
| 206 | +A redaction regex written against `api_key = "..."` — double quotes, as in the |
| 207 | +`pkg/config/testdata/*.toml` fixtures — matches nothing against a config the CLI |
| 208 | +actually wrote, and the key goes to the log verbatim. Check redaction patterns |
| 209 | +against real output, not against a fixture. |
| 210 | + |
| 211 | +## Things that compile, pass, and are still broken |
| 212 | + |
| 213 | +Two surfaces in this repository have no test or CI job guarding them, so a green |
| 214 | +run means nothing about them. A third is guarded only in part — and both halves, |
| 215 | +the guarded one and the unguarded one, are dangerous in their own way. |
| 216 | + |
| 217 | +### `REFERENCE.md` is generated |
| 218 | + |
| 219 | +It is produced by `tools/generate-reference`, and the generated regions are |
| 220 | +fenced by `<!-- GENERATE... -->` / `<!-- GENERATE_END -->` markers. |
| 221 | + |
| 222 | +- `go run ./tools/generate-reference --check` regenerates to a temp file, diffs, |
| 223 | + and **exits 1 when `REFERENCE.md` is stale** |
| 224 | +- **no CI job runs it.** Nothing catches a command-surface change that leaves |
| 225 | + the file behind |
| 226 | + |
| 227 | +So run it yourself when a diff touches commands, flags or examples. And flag a |
| 228 | +hand-edit *inside* a `GENERATE` block: it is not wrong today, it is deleted on |
| 229 | +the next regeneration, silently. |
| 230 | + |
| 231 | +### The website is a second consumer |
| 232 | + |
| 233 | +The same generator feeds the docs website, and it emits positional arguments |
| 234 | +only when `Annotations["cli.arguments"]` is set on the command (`AGENTS.md` |
| 235 | +§ Cobra Example and output for website docs). A new command that takes |
| 236 | +positional arguments without that annotation documents them nowhere. |
| 237 | + |
| 238 | +The review consequence: a command-surface change is not finished inside this |
| 239 | +repository. Say so, so the docs change is not discovered after release. |
| 240 | + |
| 241 | +### `CLITelemetry` JSON tags are a wire contract |
| 242 | + |
| 243 | +`pkg/hookdeck/telemetry.go` defines `CLITelemetry`, sent on every API request |
| 244 | +in the `X-Hookdeck-CLI-Telemetry` header. Its `json:"..."` tags — |
| 245 | +`command_path`, `invocation_id`, `command_flags`, `mcp_client` and the rest — |
| 246 | +are field names that Hookdeck's usage analytics query by name. |
| 247 | + |
| 248 | +Whether a rename goes red depends on which tag it is, and the split is not |
| 249 | +uniform. Most are pinned by literal key name in `pkg/hookdeck/telemetry_test.go`, |
| 250 | +which marshals the struct into a `map[string]interface{}`; that file carries no |
| 251 | +build tag and no `testing.Short()` guard, so it runs under the **required** |
| 252 | +`unit-test` check and renaming one of those tags goes red. At least one is not: |
| 253 | +`command_flags` is asserted only by `TestTelemetryLoginCommandFlagsProxy` in |
| 254 | +`test/acceptance/telemetry_test.go`, which skips itself unless |
| 255 | +`HOOKDECK_CLI_TESTING_CLI_KEY` is set — and the `acceptance-telemetry` job does |
| 256 | +not set it. Renaming that one goes green and empties the dashboard. |
| 257 | + |
| 258 | +Do not carry that split around in your head, and do not trust the sentence above |
| 259 | +to still be true: it rots every time a test moves. Derive it for the tag in |
| 260 | +front of you. |
| 261 | + |
| 262 | +``` |
| 263 | +grep -rn '<the json tag>' --include='*_test.go' . |
| 264 | +``` |
| 265 | + |
| 266 | +No hit means nothing guards it. Hits only under `test/acceptance/` mean check |
| 267 | +whether that test runs at all — see *A green acceptance job is not a run* above. |
| 268 | + |
| 269 | +The trap, when it does go red, is the next move. The obvious way to get the |
| 270 | +build green again is to update the expected key in the test — which restores the |
| 271 | +signal while the dashboards built on the old name stay empty. **Treat a diff that edits both |
| 272 | +`telemetry.go` and its expected key names as a breaking change to a consumer |
| 273 | +outside this repository**, and say so explicitly. Adding a field is safe; |
| 274 | +renaming or removing one is not. |
| 275 | + |
| 276 | +## Docs that move with the code |
| 277 | + |
| 278 | +A change to a command's surface is incomplete without: |
| 279 | + |
| 280 | +- **`REFERENCE.md`** — regenerated, per above |
| 281 | +- **Cobra `Short` and `Long`**, via the shared helpers in `pkg/cmd/helptext.go` |
| 282 | + rather than per-command literals (`AGENTS.md` § Command help text) |
| 283 | +- **`KNOWN_ISSUES.md`**, when the change *fixes* user-visible behaviour. The |
| 284 | + file documents each issue by symptom, cause, affected versions and |
| 285 | + recommended fix. A fix that ships while its entry still says "Affected: |
| 286 | + v2.5.0 and earlier" with no resolution tells users to keep working around |
| 287 | + something that no longer exists. |
| 288 | + |
| 289 | +## What not to raise |
| 290 | + |
| 291 | +- **A `CHANGELOG.md` entry.** The file states plainly that it is no longer |
| 292 | + maintained; release notes come from the GitHub Releases page and are drafted |
| 293 | + at release time. Asking for an entry asks for something the repository |
| 294 | + deliberately stopped doing. |
| 295 | +- **The `version` field in `package.json`.** It is machine-written: |
| 296 | + `release.yml` sets it from the tag and commits it during `publish-npm`. It is |
| 297 | + expected to lag the tag in the tree, and a human editing it by hand is noise, |
| 298 | + not a finding. |
| 299 | +- **Style the repository does not enforce**, or a refactor nobody asked for. A |
| 300 | + review that returns twenty comments buries the one that mattered. |
0 commit comments