feat(reusable): openapi-types-drift gate — detect types.gen.ts drift from contracts spec#72
feat(reusable): openapi-types-drift gate — detect types.gen.ts drift from contracts spec#72topcoder1 wants to merge 5 commits into
Conversation
…types drift Adds a reusable GitHub Actions workflow that catches the class of bug where openapi-typescript-generated types (types.gen.ts) drift from the contracts spec without anyone noticing — banked lesson from Wave 4.5 and Wave 4.0 PR 3a. On every PR the workflow: 1. Checks out the contracts repo at the pinned revision (head / contracts-rev file / go-mod SHA / package-json version). 2. Runs `npm run gen-ts -- <tmpfile>` from within the contracts repo. 3. Diffs the fresh output against the committed generated file. 4. Fails with a clear regen instruction and posts a sticky PR comment (first 50 lines of diff) when drift is detected; auto-removes the comment when clean. Supports four rev-source modes to accommodate repos with and without explicit SHA pins. Advisory-only soak recommended before adding to required-status-checks. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- P1: head mode: pass empty ref to actions/checkout (literal "HEAD" is treated as a branch name and fails repos without a HEAD branch) - P2: go-mod: extract 12-char commit suffix from pseudo-versions, fall back to plain SemVer tag; avoids matching timestamp digits as SHA - P2: package-json: normalise git+URL, github:, semver ^/~ specs to a checkout-safe ref; warn and fall back to default branch on unknown format - P2: contracts checkout: add inline comment clarifying that contracts_read_token is required for ANY private cross-repo checkout, including same-org repos (GITHUB_TOKEN is scoped to caller repo only) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- P1: go-mod: grep module path directly (not require+path on same line) to handle multi-line require() blocks; extract 12-char SHA from pseudo- version suffix; fall back to plain vX.Y.Z tag when no pseudo-version - P2: package-json: validate fragment length before using as checkout ref — only accept 40-char SHAs or v-prefixed tags; warn on short fragment and fall back to default branch - P2: contracts_read_token docstring: clarify it is required for ALL private contracts repos (GITHUB_TOKEN is caller-scoped, not cross-repo) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Strip // indirect comments before extracting version token in go-mod mode (awk was returning "indirect" as the version for indirect deps) - go-mod pseudo-version: pass full pseudo-version string as checkout ref (12-char suffix is not enough for actions/checkout; full v0.0.0-ts-sha form resolves via the module proxy synthetic tag) - package-json: reject semver ranges (^/~) with a clear warning — range value doesn't tell us which concrete version the lockfile resolved; add explicit exact-SemVer and v-prefixed tag paths; keep full-SHA fragment path - README: correct contracts_read_token note — token required for ALL private repos (same-org included); GITHUB_TOKEN is caller-scoped only Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- go-mod pseudo-version: pseudo-versions are not real git tags in the contracts repo — emit a clear warning and fall back to default branch rather than passing a string that actions/checkout cannot resolve - package-json: add contracts_package_name input for scoped or differently-named npm packages; fall back to repo basename as before but improve the warning message when the key is not found Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
Risk class: This PR touches one of the blocked path categories from Auto-merge is refused by (This is a policy notice, not a code-quality failure. The classify job itself does not fail — required CI checks remain authoritative for "is the code green.") |
|
Coverage Floor — mode:
|
| matched_line=$(grep -E "^\s*${module_path}(\s|$)" "$gomod" | head -1 || true) | ||
| if [ -z "$matched_line" ]; then | ||
| # Try full owner/repo path (for replace directives) | ||
| matched_line=$(grep -E "${{ inputs.contracts_repo }}" "$gomod" | grep -v '^//' | head -1 || true) |
There was a problem hiding this comment.
Injection anti-pattern — ${{ inputs.contracts_repo }} interpolated directly into a grep regex.
GitHub Actions expands ${{ }} expressions into the script text before bash executes it. A contracts_repo value containing bash metacharacters (e.g. $(id), backticks, or unbalanced parens) would be executed by the shell. Even without malice, a repo name containing regex-special chars (. matches any char, +, (, etc.) would silently mis-match lines in go.mod.
The rest of this step correctly gates contracts_repo behind the module_path variable derived via basename and bash-variable expansion. Apply the same pattern here — add CONTRACTS_REPO to the step's env: block and reference it as a shell variable:
| matched_line=$(grep -E "${{ inputs.contracts_repo }}" "$gomod" | grep -v '^//' | head -1 || true) | |
| matched_line=$(grep -F "$CONTRACTS_REPO" "$gomod" | grep -v '^//' | head -1 || true) |
And add to the env: block of this step:
CONTRACTS_REPO: ${{ inputs.contracts_repo }}
(grep -F disables regex interpretation, which is correct here — you're looking for a literal module path, not a pattern.)
| # @topcoder1/asm-contracts-v2) must set contracts_package_name. | ||
| if [ -n "${{ inputs.contracts_package_name }}" ]; then | ||
| pkg_name="${{ inputs.contracts_package_name }}" | ||
| else |
There was a problem hiding this comment.
Same injection anti-pattern — contracts_package_name and contracts_repo interpolated directly into shell.
Lines 239–242 embed ${{ inputs.contracts_package_name }} and ${{ inputs.contracts_repo }} raw into the script body. A package name containing " breaks the double-quoted string at line 240; one containing $(cmd) runs cmd. These inputs should be passed through the env: block like the other inputs in this step (REV_SOURCE, REV_FILE).
| # @topcoder1/asm-contracts-v2) must set contracts_package_name. | |
| if [ -n "${{ inputs.contracts_package_name }}" ]; then | |
| pkg_name="${{ inputs.contracts_package_name }}" | |
| else | |
| PKG_NAME_INPUT="${CONTRACTS_PACKAGE_NAME:-}" | |
| REPO_BASENAME="$(basename "$CONTRACTS_REPO")" | |
| if [ -n "$PKG_NAME_INPUT" ]; then | |
| pkg_name="$PKG_NAME_INPUT" | |
| else | |
| pkg_name="$REPO_BASENAME" | |
| fi |
And add to the env: block of this step:
CONTRACTS_PACKAGE_NAME: ${{ inputs.contracts_package_name }}
CONTRACTS_REPO: ${{ inputs.contracts_repo }}|
Flagged 2 issues inline — both are the same injection anti-pattern ( |
Summary
Adds a reusable GitHub Actions workflow that catches the class of bug where
openapi-typescript-generated types (types.gen.ts) drift from the OpenAPI contracts spec without anyone noticing. Banked lesson from Wave 4.5 ("types.gen.ts hand-edit drift confirmed") and Wave 4.0 PR 3a (had to regen mid-PR because Wave 1/1b contract changes never propagated).What it does on every PR:
contracts_rev_sourcemodes:head/contracts-revfile /go-modSHA /package-jsonversion).npm run gen-ts -- <tempfile>from within the contracts repo.Files changed
.github/workflows/openapi-types-drift.yml— new reusable workflowREADME.md— documents the new gateAdvisory-only soak
ADVISORY ONLY — do NOT add to required-status-checks yet. Run for ~1 week on the first consumer (asm-web-v2 BB Pipelines analog) to confirm no false positives before gating. The check context name is
drift / openapi-types-driftfor when you're ready to require it.Known limitation — no SHA pin
The primary topology this gate is designed for (
contracts_rev_source: head) evaluates drift against contracts HEAD at CI time. If contracts HEAD advances between CI runs, the gate may report different results for the same PR. The permanent fix is Path C: add a.contracts-revfile to the consumer repo containing a 40-char SHA, then switch tocontracts_rev_source: contracts-rev. Deferred to a future wave — tracked as architectural debt.Codex pre-review
Codex rounds: 5
Rounds 1–4 fixed: HEAD-as-branch-name bug (P1), go-mod multi-line block parsing (P2), package-json semver range rejection (P2),
// indirectcomment stripping (P2),contracts_package_nameinput for scoped packages (P2), token-scope documentation (P2). Round 5 returned the same go-mod pseudo-version P2 as round 4 (repeat, no new findings) → two consecutive rounds with zero new issues → iteration complete.Verdict: PASS — no P1s remaining; open P2 is the go-mod pseudo-version limitation which is inherent to the architecture (pseudo-versions are not real git tags) and is documented with a clear warning in the workflow.
Auto-merge rationale: CI infra adding a new advisory-only workflow. Does not touch auth, migrations, billing, or prod deploy.
.github/workflows/change is in the reusable repo itself (caller-automerge doesn't apply here — this repo always requires manual merge per fleet policy).