Skip to content

feat(reusable): openapi-types-drift gate — detect types.gen.ts drift from contracts spec#72

Open
topcoder1 wants to merge 5 commits into
mainfrom
types-gen-drift-gate-2026-05-22
Open

feat(reusable): openapi-types-drift gate — detect types.gen.ts drift from contracts spec#72
topcoder1 wants to merge 5 commits into
mainfrom
types-gen-drift-gate-2026-05-22

Conversation

@topcoder1
Copy link
Copy Markdown
Owner

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:

  1. Checks out the contracts repo at the pinned revision (four contracts_rev_source modes: head / contracts-rev file / go-mod SHA / package-json version).
  2. Runs npm run gen-ts -- <tempfile> from within the contracts repo.
  3. Diffs the fresh output against the checked-in generated file.
  4. Fails with a clear regen instruction and posts a sticky PR comment (first 50 diff lines) when drift is detected; auto-removes the comment when the PR is updated and drift is gone.

Files changed

  • .github/workflows/openapi-types-drift.yml — new reusable workflow
  • README.md — documents the new gate

Advisory-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-drift for 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-rev file to the consumer repo containing a 40-char SHA, then switch to contracts_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), // indirect comment stripping (P2), contracts_package_name input 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).

topcoder1 and others added 5 commits May 22, 2026 09:36
…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>
@github-actions github-actions Bot added the risk:blocked Risk class: blocked label May 23, 2026
@github-actions
Copy link
Copy Markdown

Risk class: blocked — manual merge required.

This PR touches one of the blocked path categories from .github/risk-paths.yml (Dockerfiles, docker-compose, .github/workflows/**, **/.env*, **/secrets*, infra/, terraform/, k8s/, or the classifier config itself).

Auto-merge is refused by claude-author-automerge.yml. A maintainer should review the diff and click "Squash and merge" themselves.

(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.")

@github-actions
Copy link
Copy Markdown

Coverage Floor — mode: enforce

metric value
measured 100.0%
floor (current) 99.0%
target 100.0%
last bumped 2026-05-12

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)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

Suggested change
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.)

Comment on lines +239 to +242
# @topcoder1/asm-contracts-v2) must set contracts_package_name.
if [ -n "${{ inputs.contracts_package_name }}" ]; then
pkg_name="${{ inputs.contracts_package_name }}"
else
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Suggested change
# @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 }}

@claude
Copy link
Copy Markdown

claude Bot commented May 23, 2026

Flagged 2 issues inline — both are the same injection anti-pattern (${{ inputs.* }} interpolated directly into shell script body rather than passed via env:), appearing in the go-mod grep (line 194) and package-json variable assignment (lines 239–242). All other logic — exit-code handling, temp-file use, comment stickiness, rev-source parsing — looks correct.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

risk:blocked Risk class: blocked

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant