Skip to content

Commit da7502a

Browse files
somanshreddyclaude
andauthored
docs(release): add a command-surface regression check to the release gate (PRINFRA-499) (#284)
The command surface is generated from an upstream OpenAPI spec this repo does not control, so a resync can remove a command, rename a flag, flip one to required, or reroute what the CLI sends, with no commit in this repo saying so. The resync lands as a single "codegen: resync gen/" line and /changelog-cli collapses it into Internal, so the commit log and the changelog — the two things a releaser reads — are exactly where such a break is invisible. Adds a required pre-release step that reduces gen/ to a normalized manifest of contract-bearing fields at two refs and diffs them. The grep is an allowlist rather than a denylist: a field omitted from a denylist is invisible forever, and the first draft of this check proved the point by filtering out Source, BodyEncoding and JSONName, each of which reroutes the request. The allowlist now covers every field gen/ emits except the four that are pure prose. Request and response schemas are collapsed to a presence marker instead of dropped, since their contents churn constantly but their presence decides whether --request-schema and --response-schema exist. Verified against a worktree carrying six simultaneous breaks — a newly added already-required flag, Source query->body, a deleted RequestSchema, BodyEncoding json->multipart, a dropped Destructive, and a JSONName rename: the manifest diff surfaces all six. The v0.6.0..main range correctly produces empty output. A shape diff cannot see a flag that keeps its signature and stops doing anything, which is what just happened to --enable-caption on video-translate and lipsync, so a second pass greps new help text for deprecation language. Also corrects the bump rule, which listed codegen resyncs as patch-worthy without qualification and would have mislabeled a breaking resync as a patch. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 19d2fc9 commit da7502a

3 files changed

Lines changed: 242 additions & 8 deletions

File tree

RELEASE.md

Lines changed: 50 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -33,23 +33,33 @@ gh workflow run dev-release.yml
3333

3434
### Pre-release checklist
3535

36+
Every step compares against `origin/main` and the last stable tag, so start from a fetched checkout:
37+
38+
```bash
39+
git fetch --tags origin
40+
LAST_STABLE=$(git tag --list 'v*' --sort=-v:refname | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | head -n 1)
41+
```
42+
43+
Keep `$LAST_STABLE` set for the rest of the checklist; steps 4 and 6 reuse it. It filters to release tags, so a run of `-dev.*` prereleases can't shadow the stable one.
44+
3645
1. **Review commits since last stable.** Check what's new and confirm nothing is half-finished:
3746
```bash
38-
git log $(gh release view --json tagName -q .tagName)..origin/main --oneline
47+
git log "$LAST_STABLE"..origin/main --oneline
3948
```
4049
2. **Check open PRs.** Decide if any should merge first (e.g. pending codegen resyncs, small fixes):
4150
```bash
4251
gh pr list --state open
4352
```
4453
3. **Confirm CI is green on main.** All checks should pass on the latest commit.
45-
4. **Run E2E smoke test.** With `HEYGEN_API_KEY` set, run `/e2e-cli-test` in Claude Code from the repo root. Confirm all phases pass (no FAIL). WARN on Phase 3 means the account lacks data for some get/detail commands and should be investigated. This builds the binary and exercises it against the live API (costs a small number of credits).
46-
5. **Pick the version number.** Check the last stable tag and bump according to the rules below:
54+
4. **Diff the generated command surface for regressions.** See [Checking for Regressions](#checking-for-regressions) below. Required on every stable release, not just ones that look risky — a resync that breaks the CLI looks identical in `git log` to one that doesn't. It covers `gen/` only; hand-written commands in `cmd/heygen/` are reviewed the normal way, through their PRs.
55+
5. **Run E2E smoke test.** With `HEYGEN_API_KEY` set, run `/e2e-cli-test` in Claude Code from the repo root. Confirm all phases pass (no FAIL). WARN on Phase 3 means the account lacks data for some get/detail commands and should be investigated. This builds the binary and exercises it against the live API (costs a small number of credits).
56+
6. **Pick the version number.** Check the last stable tag and bump according to the rules below:
4757
- Patch (`v0.0.x`) for bug fixes, UX polish, codegen resyncs, and additive schema changes.
48-
- Minor (`v0.x.0`) for new command groups or significant new capabilities.
58+
- Minor (`v0.x.0`) for new command groups, significant new capabilities, or **any breaking surface change found in step 4** — a resync is only a patch when it is purely additive.
4959
```bash
50-
gh release list --limit 3
60+
echo "$LAST_STABLE"
5161
```
52-
6. **Generate changelog.** Run `/changelog-cli v0.x.y` in Claude Code. Review the output and save it for the release notes.
62+
7. **Generate changelog.** Run `/changelog-cli v0.x.y` in Claude Code. Review the output and save it for the release notes. The skill reads `git log`, so it cannot see the step 4 findings — add those to the release notes yourself, at the top, under **Breaking changes** if any was breaking and **Deprecated** otherwise.
5363

5464
### Trigger the release
5565

@@ -69,16 +79,48 @@ installer, checksums, and platform archives to S3 for CDN-backed installs.
6979
CDN propagation takes up to 1 minute for the version pointer and 5 minutes
7080
for the install script.
7181

72-
5. **Verify the release was published:**
82+
1. **Verify the release was published:**
7383
```bash
7484
gh release view v0.0.5
7585
```
76-
6. **Verify the install script picks up the new version** (after CDN propagation):
86+
2. **Verify the install script picks up the new version** (after CDN propagation):
7787
```bash
7888
curl -fsSL https://static.heygen.ai/cli/install.sh | bash
7989
heygen --version
8090
```
8191

92+
## Checking for Regressions
93+
94+
`gen/` is generated from HeyGen's OpenAPI spec, which lives upstream. A resync lands as one `codegen: resync gen/ from EF <sha>` commit and `/changelog-cli` files it under Internal — so the commit log and the changelog, the two things a releaser reads, are exactly where a breaking change is invisible. Diff the generated surface instead. This covers `gen/` only; hand-written commands in `cmd/heygen/` and the hidden-endpoint list in `internal/command/hidden.go` are reviewed through their own PRs.
95+
96+
```bash
97+
scripts/release-surface.sh diff "$LAST_STABLE" origin/main
98+
```
99+
100+
The script reduces `gen/` at both refs to the fields that decide what a user can type, then diffs the two. It filters with an allowlist, since a field left out is invisible forever; `codegen/surface_allowlist_test.go` fails the build if codegen gains a field the allowlist misses, so the list cannot rot. Request and response schemas are compared by presence rather than content: their bodies churn on every resync, but whether a command *has* one decides whether `--request-schema` and `--response-schema` exist.
101+
102+
Empty output means the surface is unchanged. The script exits non-zero and says so if its own reduction matched nothing, because "no changes" and "the check is broken" otherwise look identical. Read the `<` lines (the old side) first — a removal is a break, an addition usually isn't.
103+
104+
| A `<` line showing... | Effect | Action |
105+
|---|---|---|
106+
| a command or flag `Name` gone | `unknown command` / `unknown flag`, exit 2 | For a command, re-register the old path in `cmd/heygen/aliases.go`. There is no flag equivalent, so call it out. |
107+
| a stricter input: `Required` false→true, a dropped `Enum` value, narrowed `Min`/`Max`, changed `Type` | previously valid invocations now rejected before the request is sent | Breaking. Name the command, the flag, and the old vs new constraint. |
108+
| `Args` losing an entry, or a changed `Param` | positional arity changed, or the same argument now fills a different URL slot | Breaking. Show the old and new call shape. |
109+
| a changed `Source`, `JSONName`, `Default`, or `SendDefaultWhenOmitted` | same input, different request — routing, wire key, or whether a value is sent at all | Confirm it is intended; none of these change the help text, so nothing else will surface them. |
110+
| `BodyEncoding` leaving `json` | `-d/--data` is no longer registered — the builder adds it only when `BodyEncoding` is exactly `json` | Breaking for anyone passing a raw body. |
111+
| a `RequestSchema`/`ResponseSchema` `<present>` line gone | `--request-schema` / `--response-schema` no longer exist there | Breaking for agents that introspect before calling. |
112+
| `Destructive`, `Endpoint`, or `Method` changing | `--force` and the confirmation prompt appear or disappear; or the command now calls something else | Rarely intended. Confirm before releasing. |
113+
114+
Additions are usually safe, with two exceptions that show up only as `>` lines: a new flag that is already `Required: true`, and a new `Args` entry. Both make every prior invocation of that command exit 2. (`Deprecated: true` appearing is not a break — the flag still works and still sends its value — but it is worth a release note.)
115+
116+
Finally, a flag can stop doing anything without changing shape at all, which the diff above cannot see because it excludes help text:
117+
118+
```bash
119+
scripts/release-surface.sh deprecated "$LAST_STABLE" origin/main
120+
```
121+
122+
Each line names a command and the flag that went quiet, e.g. `video-translate create --enable-caption`. It matches loosely on purpose; a false positive is obvious once you can see which flag it named. A real hit belongs in the release notes, because a user whose script sets that flag gets no error and no warning — just different output than they asked for.
123+
82124
## Version Scheme
83125

84126
All versions use semver with a `v` prefix. The `v` prefix is required everywhere: git tags, `--version` output, `heygen update --version` input, JSON responses, and install script flags.

codegen/surface_allowlist_test.go

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
package main
2+
3+
import (
4+
"os"
5+
"path/filepath"
6+
"regexp"
7+
"strings"
8+
"testing"
9+
)
10+
11+
// The release-time surface check (scripts/release-surface.sh, documented in
12+
// RELEASE.md) reduces gen/ to the fields that decide what a user can type. It
13+
// filters with an allowlist, so a field the template emits but the allowlist
14+
// omits is invisible to the check forever — a regression in it would pass a
15+
// release gate that reports "clean".
16+
//
17+
// This shipped broken once: SendDefaultWhenOmitted, which decides whether a
18+
// value is sent when the user omits the flag, was emitted by the template and
19+
// missing from the allowlist. It escaped review because the inventory was taken
20+
// over gen/ rather than the template, and no endpoint currently triggers its
21+
// emit condition — so it was absent from the output while being one spec change
22+
// away from appearing. Hence this test reads the template, not gen/.
23+
//
24+
// Excluded on purpose:
25+
// - prose fields, which churn on nearly every resync and are not contract
26+
// - container lines, whose entries are matched individually by the allowlist
27+
var (
28+
proseFields = map[string]bool{"Description": true, "Summary": true, "Help": true, "Examples": true}
29+
containerFields = map[string]bool{"Flags": true, "Args": true}
30+
31+
templateFieldRe = regexp.MustCompile(`(?m)^\s*([A-Za-z][A-Za-z0-9]*):`)
32+
allowlistRe = regexp.MustCompile(`(?m)^SURFACE_FIELDS='(.*)'$`)
33+
)
34+
35+
func TestSurfaceAllowlistCoversEveryTemplateField(t *testing.T) {
36+
tmpl, err := os.ReadFile(filepath.Join("templates", "command.go.tmpl"))
37+
if err != nil {
38+
t.Fatalf("read template: %v", err)
39+
}
40+
script, err := os.ReadFile(filepath.Join("..", "scripts", "release-surface.sh"))
41+
if err != nil {
42+
t.Fatalf("read release-surface.sh: %v", err)
43+
}
44+
45+
m := allowlistRe.FindSubmatch(script)
46+
if m == nil {
47+
t.Fatal("no SURFACE_FIELDS assignment found in scripts/release-surface.sh — " +
48+
"if it was renamed or reformatted, update this test, because without it the allowlist can rot silently")
49+
}
50+
allowed := make(map[string]bool)
51+
for _, alt := range strings.Split(string(m[1]), "|") {
52+
// Alternatives look like `Group:` or `\{(Name` — take the leading identifier.
53+
if name := regexp.MustCompile(`[A-Za-z][A-Za-z0-9]*`).FindString(alt); name != "" {
54+
allowed[name] = true
55+
}
56+
}
57+
58+
var missing []string
59+
for _, f := range templateFieldRe.FindAllSubmatch(tmpl, -1) {
60+
name := string(f[1])
61+
if allowed[name] || proseFields[name] || containerFields[name] {
62+
continue
63+
}
64+
missing = append(missing, name)
65+
}
66+
67+
if len(missing) > 0 {
68+
t.Errorf("codegen/templates/command.go.tmpl emits %v, which SURFACE_FIELDS in "+
69+
"scripts/release-surface.sh does not match. A change to that field would be invisible to the "+
70+
"release regression check. Add it to the allowlist, or to proseFields/containerFields here if it "+
71+
"genuinely is not part of the command surface.", missing)
72+
}
73+
}
74+
75+
// The allowlist is only meaningful if it actually selects the lines it claims
76+
// to. Guards against a well-formed regex that matches nothing — the failure
77+
// mode the script's own runtime check exists for, caught here at build time.
78+
func TestSurfaceAllowlistMatchesRealGeneratedLines(t *testing.T) {
79+
script, err := os.ReadFile(filepath.Join("..", "scripts", "release-surface.sh"))
80+
if err != nil {
81+
t.Fatalf("read release-surface.sh: %v", err)
82+
}
83+
m := allowlistRe.FindSubmatch(script)
84+
if m == nil {
85+
t.Fatal("no SURFACE_FIELDS assignment found")
86+
}
87+
// Go's regexp is RE2; the pattern is a POSIX ERE alternation, which is a
88+
// compatible subset for the constructs used here.
89+
re, err := regexp.Compile(`^\s*(` + string(m[1]) + `)`)
90+
if err != nil {
91+
t.Fatalf("SURFACE_FIELDS is not a valid pattern: %v", err)
92+
}
93+
94+
sample, err := os.ReadFile(filepath.Join("..", "gen", "video.go"))
95+
if err != nil {
96+
t.Fatalf("read gen/video.go: %v", err)
97+
}
98+
var matched int
99+
for _, line := range strings.Split(string(sample), "\n") {
100+
if re.MatchString(line) {
101+
matched++
102+
}
103+
}
104+
if matched == 0 {
105+
t.Error("SURFACE_FIELDS matched no line in gen/video.go — the release check would report every " +
106+
"release as clean")
107+
}
108+
}

scripts/release-surface.sh

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
#!/usr/bin/env bash
2+
# Release-time regression checks over the generated command surface.
3+
#
4+
# See RELEASE.md "Checking for Regressions" for how to read the output.
5+
#
6+
# release-surface.sh diff <old-ref> <new-ref> what changed in the surface
7+
# release-surface.sh deprecated <old-ref> <new-ref> flags that newly went quiet
8+
#
9+
# Both checks fail toward printing nothing, and nothing looks identical to a
10+
# clean bill, so both assert their own input is sane before reporting.
11+
set -uo pipefail
12+
13+
# Every field in gen/ that decides what a user can type.
14+
#
15+
# This is an ALLOWLIST, and that direction is load-bearing: a field left out is
16+
# invisible to the check forever. It is not maintained by hand alone —
17+
# codegen/surface_allowlist_test.go fails when the template gains a field that
18+
# is missing here, so the list cannot silently rot.
19+
#
20+
# Excluded on purpose: the four pure-prose fields (Description, Summary, Help,
21+
# Examples), which churn on nearly every resync and are not part of the
22+
# contract, and the two container lines (Flags, Args), whose entries are matched
23+
# individually instead.
24+
SURFACE_FIELDS='var [A-Za-z0-9_]+ = &command\.Spec\{|Group:|Name:|Endpoint:|Method:|BodyEncoding:|Paginated:|Destructive:|Deprecated:|RequestSchema:|ResponseSchema:|Required:|Enum:|Min:|Max:|Type:|Default:|Source:|JSONName:|SendDefaultWhenOmitted:|\{(Name|Param):'
25+
26+
die() { echo "release-surface: $*" >&2; exit 1; }
27+
28+
gen_at() {
29+
# -r so a future move to subdirectories under gen/ is not silently dropped.
30+
git ls-tree -r --name-only "$1" gen/ | while read -r f; do git show "$1:$f"; done
31+
}
32+
33+
# Reduce gen/ to just the contract-bearing lines.
34+
#
35+
# Request and response schemas are collapsed to a marker rather than dropped:
36+
# their contents churn on every resync, but whether a command *has* one decides
37+
# whether --request-schema and --response-schema exist.
38+
surface() {
39+
gen_at "$1" \
40+
| sed -E 's/^([[:space:]]*(RequestSchema|ResponseSchema)):.*/\1: <present>/' \
41+
| grep -E "^[[:space:]]*($SURFACE_FIELDS)"
42+
}
43+
44+
# Flags whose help text says they stopped doing anything. Invisible to the
45+
# surface diff, which excludes help text by design, so this reads it directly
46+
# and reports the owning command to make the finding writeable as a release note.
47+
deprecated_flags() {
48+
gen_at "$1" \
49+
| awk -F'"' '
50+
/^\tGroup:/ { grp=$2 }
51+
/^\tName:/ { cmd=$2 }
52+
/^\t\t\tName:/ { flag=$2 }
53+
/^\t\t\tHelp:.*([Dd]eprecat|no longer|ignored)/ { print grp, cmd, "--" flag }
54+
' | sort
55+
}
56+
57+
cmd=${1:-}; old=${2:-}; new=${3:-}
58+
[ -n "$cmd" ] && [ -n "$old" ] && [ -n "$new" ] || die "usage: $0 {diff|deprecated} <old-ref> <new-ref>"
59+
git rev-parse --verify --quiet "$old" >/dev/null || die "cannot resolve ref '$old'"
60+
git rev-parse --verify --quiet "$new" >/dev/null || die "cannot resolve ref '$new'"
61+
62+
case "$cmd" in
63+
diff)
64+
surface "$old" > /tmp/surface-old.txt
65+
surface "$new" > /tmp/surface-new.txt
66+
# A reduction that matched nothing is a broken check, not an unchanged
67+
# surface. Without this the two look identical: both print no diff.
68+
[ -s /tmp/surface-old.txt ] && [ -s /tmp/surface-new.txt ] \
69+
|| die "surface() matched nothing - the check is broken, do not read the result as clean"
70+
diff /tmp/surface-old.txt /tmp/surface-new.txt
71+
;;
72+
deprecated)
73+
# Only the old side is asserted: an empty new side is a legitimate result
74+
# (no deprecated flags anywhere), and an empty old side is normal too, so
75+
# the sanity check here is that gen/ resolved at all.
76+
# wc, not `grep -q`: grep exits on the first match, which SIGPIPEs the
77+
# upstream git show and trips pipefail, failing a check that actually passed.
78+
[ "$(gen_at "$old" | wc -l)" -gt 0 ] || die "gen/ is empty at '$old' - the check is broken"
79+
deprecated_flags "$old" > /tmp/dep-old.txt
80+
deprecated_flags "$new" > /tmp/dep-new.txt
81+
comm -13 /tmp/dep-old.txt /tmp/dep-new.txt
82+
;;
83+
*) die "unknown check '$cmd' (expected: diff, deprecated)" ;;
84+
esac

0 commit comments

Comments
 (0)