From 043f2dd95b48579a1530d00eb9c60596c28b8809 Mon Sep 17 00:00:00 2001 From: topcoder1 Date: Fri, 22 May 2026 09:36:27 -0700 Subject: [PATCH 1/5] feat(reusable): add openapi-types-drift workflow to detect generated-types drift MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 -- ` 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) --- .github/workflows/openapi-types-drift.yml | 323 ++++++++++++++++++++++ README.md | 28 ++ 2 files changed, 351 insertions(+) create mode 100644 .github/workflows/openapi-types-drift.yml diff --git a/.github/workflows/openapi-types-drift.yml b/.github/workflows/openapi-types-drift.yml new file mode 100644 index 0000000..610e6e9 --- /dev/null +++ b/.github/workflows/openapi-types-drift.yml @@ -0,0 +1,323 @@ +name: OpenAPI Types Drift Check (reusable) + +# Detects drift between a committed generated-types file (e.g. src/api/types.gen.ts) +# and what `openapi-typescript` would produce from the contracts spec today. +# +# Topology this was designed for: +# - A "contracts" repo that owns the OpenAPI spec (openapi/v2.yaml) and a +# `gen-ts.sh` / `npm run gen-ts` script that writes types.gen.ts. +# - A "consumer" repo (this caller) that commits the generated types file +# and rebuilds TypeScript from it. +# - No compile-time SHA pin between the two repos yet (a .contracts-rev file +# is the clean fix — see NOTE below; deferred to a future wave). +# +# What this workflow does on every PR: +# 1. Checks out the contracts repo at the SHA declared by `contracts_rev_source`. +# Supported sources: +# go-mod — reads `go.mod` replace directive / require SHA for +# the contracts module (Go-flavoured workspaces). +# package-json — reads `dependencies` or `devDependencies` SHA/semver +# for the contracts package name. +# contracts-rev — reads a plain .contracts-rev file in the caller root +# (most explicit; 40-char SHA). +# head — always checks out contracts HEAD (advisory-only / +# no-pin repos; drift may accumulate across weeks). +# 2. Re-runs `npm run gen-ts -- ` against the contracts spec. +# 3. `diff` the fresh output against the PR's committed types file. +# 4. If drift found: FAIL with a clear regen instruction, and post a sticky +# PR comment with the first ~50 lines of the diff. +# 5. If clean: pass. +# +# Advisory-only soak: do NOT add this check to required-status-checks until +# after ~1 week of advisory runs to rule out false positives. See the caller +# PR body for the planned gating timeline. +# +# NOTE on SHA pinning: +# When contracts_rev_source=head, this gate detects drift as of contracts HEAD +# at CI time. If contracts HEAD advances between your PR's CI run and the next +# run, the gate may report spurious drift. The permanent fix is to add a +# .contracts-rev file to the consumer repo and switch to contracts_rev_source= +# contracts-rev — then drift is always evaluated against the pinned SHA. +# See wave-4.5 lesson "types.gen.ts hand-edit drift confirmed" for context. + +on: + workflow_call: + inputs: + contracts_repo: + description: >- + GitHub slug (owner/repo) of the contracts repository that owns the + OpenAPI spec and the gen-ts script. E.g. "topcoder1/asm-contracts-v2". + required: true + type: string + contracts_rev_source: + description: >- + How to determine the contracts revision to check out. One of: + head — always use contracts HEAD (no-pin repos) + contracts-rev — read a .contracts-rev file in the caller root (40-char SHA) + go-mod — parse go.mod for the contracts module SHA + package-json — parse package.json for the contracts package version/SHA + required: false + type: string + default: "head" + contracts_rev_file: + description: >- + Path to the pin file when contracts_rev_source=contracts-rev. + Relative to the consumer repo root. Default ".contracts-rev". + required: false + type: string + default: ".contracts-rev" + contracts_gen_cmd: + description: >- + Command to regenerate types, run from inside the contracts repo. + Must accept a positional argument: the output file path. + Default: "npm run gen-ts --" + required: false + type: string + default: "npm run gen-ts --" + contracts_spec_path: + description: >- + Path to the OpenAPI spec inside the contracts repo, relative to its + root. Used only for display in error messages. Default "openapi/v2.yaml". + required: false + type: string + default: "openapi/v2.yaml" + generated_types_path: + description: >- + Repo-root-relative path to the committed generated-types file in the + consumer (caller) repo. Default "src/api/types.gen.ts". + required: false + type: string + default: "src/api/types.gen.ts" + node_version: + description: "Node.js version for running the codegen. Default '20'." + required: false + type: string + default: "20" + secrets: + contracts_read_token: + description: >- + Optional PAT or token used to clone a private contracts repo. + When unset, the workflow uses the built-in GITHUB_TOKEN for same-org + repos or attempts an unauthenticated clone for public repos. + Required scopes: contents:read on the contracts repo. + required: false + +jobs: + drift_check: + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: write + steps: + - name: Checkout consumer repo (PR head) + uses: actions/checkout@v5 + with: + fetch-depth: 1 + path: consumer + + - name: Resolve contracts revision + id: resolve_rev + env: + REV_SOURCE: ${{ inputs.contracts_rev_source }} + REV_FILE: ${{ inputs.contracts_rev_file }} + run: | + set -euo pipefail + case "$REV_SOURCE" in + head) + echo "rev=HEAD" >> "$GITHUB_OUTPUT" + echo "rev_display=HEAD (no pin)" >> "$GITHUB_OUTPUT" + ;; + contracts-rev) + pin_file="consumer/$REV_FILE" + if [ ! -f "$pin_file" ]; then + echo "::error::contracts_rev_source=contracts-rev but '$REV_FILE' not found in repo root." + echo "Create it with the 40-char SHA of the contracts commit you're targeting." + exit 1 + fi + rev=$(tr -d '[:space:]' < "$pin_file") + if ! echo "$rev" | grep -qE '^[0-9a-f]{40}$'; then + echo "::error::$REV_FILE must contain a 40-char lowercase hex SHA. Got: '$rev'" + exit 1 + fi + echo "rev=$rev" >> "$GITHUB_OUTPUT" + echo "rev_display=$rev" >> "$GITHUB_OUTPUT" + ;; + go-mod) + gomod="consumer/go.mod" + if [ ! -f "$gomod" ]; then + echo "::error::contracts_rev_source=go-mod but go.mod not found." + exit 1 + fi + # Accepts both `require` SHA pseudo-versions and `replace` directives + rev=$(grep -E '(require|replace).*${{ inputs.contracts_repo }}' "$gomod" \ + | grep -oE '[0-9a-f]{12,40}' | head -1 || true) + if [ -z "$rev" ]; then + echo "::warning::Could not find a SHA for '${{ inputs.contracts_repo }}' in go.mod — falling back to HEAD." + echo "rev=HEAD" >> "$GITHUB_OUTPUT" + echo "rev_display=HEAD (go-mod SHA not found)" >> "$GITHUB_OUTPUT" + else + echo "rev=$rev" >> "$GITHUB_OUTPUT" + echo "rev_display=$rev (from go.mod)" >> "$GITHUB_OUTPUT" + fi + ;; + package-json) + pkgjson="consumer/package.json" + if [ ! -f "$pkgjson" ]; then + echo "::error::contracts_rev_source=package-json but package.json not found." + exit 1 + fi + repo_name=$(basename "${{ inputs.contracts_repo }}") + rev=$(jq -r --arg pkg "$repo_name" \ + '(.dependencies // {}) + (.devDependencies // {}) | .[$pkg] // ""' \ + "$pkgjson") + if [ -z "$rev" ]; then + echo "::warning::Package '$repo_name' not found in package.json deps — falling back to HEAD." + echo "rev=HEAD" >> "$GITHUB_OUTPUT" + echo "rev_display=HEAD (package-json key not found)" >> "$GITHUB_OUTPUT" + else + echo "rev=$rev" >> "$GITHUB_OUTPUT" + echo "rev_display=$rev (from package.json)" >> "$GITHUB_OUTPUT" + fi + ;; + *) + echo "::error::Unknown contracts_rev_source '$REV_SOURCE'. Must be: head | contracts-rev | go-mod | package-json" + exit 1 + ;; + esac + + - name: Checkout contracts repo + uses: actions/checkout@v5 + with: + repository: ${{ inputs.contracts_repo }} + ref: ${{ steps.resolve_rev.outputs.rev }} + path: contracts + token: ${{ secrets.contracts_read_token || github.token }} + fetch-depth: 1 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: ${{ inputs.node_version }} + cache: npm + cache-dependency-path: contracts/package-lock.json + + - name: Install contracts dev deps + working-directory: contracts + run: npm ci --prefer-offline + + - name: Re-generate types to temp file + id: codegen + working-directory: contracts + env: + GEN_CMD: ${{ inputs.contracts_gen_cmd }} + TYPES_PATH: ${{ inputs.generated_types_path }} + REV: ${{ steps.resolve_rev.outputs.rev_display }} + SPEC: ${{ inputs.contracts_spec_path }} + run: | + set -euo pipefail + FRESH_FILE="$(mktemp /tmp/types.gen.XXXXXX.ts)" + echo "Running: $GEN_CMD $FRESH_FILE (contracts rev: $REV, spec: $SPEC)" + $GEN_CMD "$FRESH_FILE" + echo "fresh_file=$FRESH_FILE" >> "$GITHUB_OUTPUT" + echo "Codegen complete: $(wc -l < "$FRESH_FILE") lines written." + + - name: Diff against committed types + id: diff + env: + FRESH_FILE: ${{ steps.codegen.outputs.fresh_file }} + COMMITTED_FILE: consumer/${{ inputs.generated_types_path }} + REV: ${{ steps.resolve_rev.outputs.rev_display }} + CONTRACTS_REPO: ${{ inputs.contracts_repo }} + TYPES_PATH: ${{ inputs.generated_types_path }} + run: | + set -euo pipefail + if diff -u "$COMMITTED_FILE" "$FRESH_FILE" > /tmp/types-drift.diff 2>&1; then + echo "drift=0" >> "$GITHUB_OUTPUT" + echo "No drift detected — committed types match regenerated output." + else + echo "drift=1" >> "$GITHUB_OUTPUT" + lines=$(wc -l < /tmp/types-drift.diff | tr -d ' ') + echo "diff_lines=$lines" >> "$GITHUB_OUTPUT" + echo "::error::types.gen.ts is out of sync with contracts spec at $REV." + echo "First 60 lines of diff:" + head -60 /tmp/types-drift.diff + fi + + - name: Post sticky PR comment on drift + if: steps.diff.outputs.drift == '1' + env: + GH_TOKEN: ${{ github.token }} + PR: ${{ github.event.pull_request.number }} + REV: ${{ steps.resolve_rev.outputs.rev_display }} + TYPES_PATH: ${{ inputs.generated_types_path }} + CONTRACTS_REPO: ${{ inputs.contracts_repo }} + SPEC: ${{ inputs.contracts_spec_path }} + DIFF_LINES: ${{ steps.diff.outputs.diff_lines }} + run: | + set -euo pipefail + marker="" + existing=$(gh api "/repos/${{ github.repository }}/issues/$PR/comments" \ + --jq ".[] | select(.body | contains(\"$marker\")) | .id" | head -1) + + # Truncate diff to first 50 lines for the comment; full diff is in CI logs. + diff_snippet=$(head -50 /tmp/types-drift.diff) + + { + echo "$marker" + echo "## \`$TYPES_PATH\` is out of sync with the contracts spec" + echo "" + echo "**Gate:** \`openapi-types-drift\` (advisory — not yet required-status-check)" + echo "" + echo "The committed \`$TYPES_PATH\` does not match what \`$CONTRACTS_REPO\`'s" + echo "codegen produces from \`$SPEC\` at contracts rev \`$REV\`." + echo "($DIFF_LINES diff lines total — first 50 shown below.)" + echo "" + echo "**To fix:** run the contracts codegen locally and commit the updated file:" + echo "\`\`\`bash" + echo "# From the contracts repo root:" + echo "npm run gen-ts" + echo "# Then commit the updated $TYPES_PATH in the consumer repo." + echo "\`\`\`" + echo "" + echo "
Diff snippet (first 50 lines)" + echo "" + echo "\`\`\`diff" + echo "$diff_snippet" + echo "\`\`\`" + echo "
" + } > /tmp/drift-comment.md + + if [ -n "$existing" ]; then + gh api -X PATCH "/repos/${{ github.repository }}/issues/comments/$existing" \ + -f body="$(cat /tmp/drift-comment.md)" >/dev/null + echo "Updated existing drift comment ($existing)." + else + gh pr comment "$PR" --body-file /tmp/drift-comment.md + echo "Posted new drift comment." + fi + + - name: Remove stale drift comment when clean + if: steps.diff.outputs.drift == '0' + env: + GH_TOKEN: ${{ github.token }} + PR: ${{ github.event.pull_request.number }} + run: | + set -euo pipefail + marker="" + existing=$(gh api "/repos/${{ github.repository }}/issues/$PR/comments" \ + --jq ".[] | select(.body | contains(\"$marker\")) | .id" | head -1) + if [ -n "$existing" ]; then + gh api -X DELETE "/repos/${{ github.repository }}/issues/comments/$existing" >/dev/null + echo "Removed stale drift comment ($existing) — types are now clean." + fi + + - name: Fail if drift detected + if: steps.diff.outputs.drift == '1' + env: + REV: ${{ steps.resolve_rev.outputs.rev_display }} + TYPES_PATH: ${{ inputs.generated_types_path }} + CONTRACTS_REPO: ${{ inputs.contracts_repo }} + run: | + echo "::error::$TYPES_PATH is out of sync with $CONTRACTS_REPO at $REV." + echo "Run 'npm run gen-ts' from the contracts repo root and commit the updated file." + exit 1 diff --git a/README.md b/README.md index 86c11a3..d9915bd 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,34 @@ Runs `prettier --write` on PR-changed markdown and pushes the fix back to the br **Skipped automatically on:** fork PRs (cross-repo push impossible), closed PRs, PRs touching zero markdown. +### `openapi-types-drift.yml` + +Detects drift between a committed generated-types file (e.g. `src/api/types.gen.ts`) and what `openapi-typescript` would produce from the contracts spec today. Prevents the "types.gen.ts hand-edit drift" class of bug where contract changes in one repo never propagate to the consumer repo's generated file. + +**Topology:** designed for a dual-repo layout where a "contracts" repo owns the OpenAPI spec + codegen script and a separate "consumer" repo commits the generated file. The consumer repo installs this caller. + +**Inputs:** + +- `contracts_repo` (string, required) — GitHub slug of the contracts repo (`owner/repo`) +- `contracts_rev_source` (string, default `head`) — how to pin the contracts revision: `head` (no pin), `contracts-rev` (`.contracts-rev` file), `go-mod`, or `package-json` +- `contracts_rev_file` (string, default `.contracts-rev`) — pin file path when `contracts_rev_source=contracts-rev` +- `contracts_gen_cmd` (string, default `npm run gen-ts --`) — command run inside contracts repo; must accept a positional output-file argument +- `contracts_spec_path` (string, default `openapi/v2.yaml`) — spec path for display in error messages +- `generated_types_path` (string, default `src/api/types.gen.ts`) — repo-root-relative path to the committed generated file in the caller +- `node_version` (string, default `20`) — Node.js version for codegen + +**Secrets:** + +- `contracts_read_token` (optional) — PAT for private contracts repos in a different org; built-in `GITHUB_TOKEN` suffices for same-org private repos + +**On drift:** fails the check and posts a sticky PR comment with the first 50 lines of the diff and regen instructions. Removes the comment automatically when the PR is updated and drift is gone. + +**Advisory soak:** install with `contracts_rev_source: head` first. Do NOT add to required-status-checks until after ~1 week of advisory runs. See the caller PR body for the gating plan. + +**Known limitation:** without a `.contracts-rev` pin file, the gate 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 `.contracts-rev` and switch to `contracts_rev_source: contracts-rev`. + +**Caller template:** `~/.claude/templates/ci-workflows/callers/openapi-types-drift.yml` + ### `dependabot-auto-merge.yml` Auto-merges Dependabot PRs for patch (and optionally minor) version bumps once required checks pass. From a21ed1353045fb4e9ab0ed87d1f5489cb844df14 Mon Sep 17 00:00:00 2001 From: topcoder1 Date: Fri, 22 May 2026 09:40:46 -0700 Subject: [PATCH 2/5] fix(openapi-types-drift): address codex round-1 findings - 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) --- .github/workflows/openapi-types-drift.yml | 69 ++++++++++++++++++----- 1 file changed, 56 insertions(+), 13 deletions(-) diff --git a/.github/workflows/openapi-types-drift.yml b/.github/workflows/openapi-types-drift.yml index 610e6e9..ff1a2f6 100644 --- a/.github/workflows/openapi-types-drift.yml +++ b/.github/workflows/openapi-types-drift.yml @@ -124,8 +124,12 @@ jobs: set -euo pipefail case "$REV_SOURCE" in head) - echo "rev=HEAD" >> "$GITHUB_OUTPUT" - echo "rev_display=HEAD (no pin)" >> "$GITHUB_OUTPUT" + # Empty rev means actions/checkout@v5 uses the default branch of + # the contracts repo (equivalent to omitting `ref:`). Passing the + # literal string "HEAD" would be treated as a branch/tag name, + # which fails on repos that have no branch named "HEAD". + echo "rev=" >> "$GITHUB_OUTPUT" + echo "rev_display=HEAD (default branch, no pin)" >> "$GITHUB_OUTPUT" ;; contracts-rev) pin_file="consumer/$REV_FILE" @@ -148,13 +152,22 @@ jobs: echo "::error::contracts_rev_source=go-mod but go.mod not found." exit 1 fi - # Accepts both `require` SHA pseudo-versions and `replace` directives + # Go pseudo-versions have the form: v0.0.0-YYYYMMDDHHMMSS-XXXXXXXXXXXX + # where the last 12 hex chars are the commit SHA prefix. Plain `require` + # lines with a SemVer tag also resolve correctly via the tag name. + # Extract the 12-char SHA suffix from pseudo-versions first; fall back to + # a plain SemVer tag on the same line if no pseudo-version SHA found. rev=$(grep -E '(require|replace).*${{ inputs.contracts_repo }}' "$gomod" \ - | grep -oE '[0-9a-f]{12,40}' | head -1 || true) + | sed -n 's/.*-\([0-9a-f]\{12\}\)$/\1/p' | head -1 || true) if [ -z "$rev" ]; then - echo "::warning::Could not find a SHA for '${{ inputs.contracts_repo }}' in go.mod — falling back to HEAD." - echo "rev=HEAD" >> "$GITHUB_OUTPUT" - echo "rev_display=HEAD (go-mod SHA not found)" >> "$GITHUB_OUTPUT" + # No pseudo-version SHA — try a plain tag (e.g. v1.2.3) + rev=$(grep -E '(require|replace).*${{ inputs.contracts_repo }}' "$gomod" \ + | grep -oE 'v[0-9]+\.[0-9]+\.[0-9]+[^ ]*' | head -1 || true) + fi + if [ -z "$rev" ]; then + echo "::warning::Could not find a rev for '${{ inputs.contracts_repo }}' in go.mod — falling back to default branch." + echo "rev=" >> "$GITHUB_OUTPUT" + echo "rev_display=HEAD (go-mod rev not found)" >> "$GITHUB_OUTPUT" else echo "rev=$rev" >> "$GITHUB_OUTPUT" echo "rev_display=$rev (from go.mod)" >> "$GITHUB_OUTPUT" @@ -167,16 +180,38 @@ jobs: exit 1 fi repo_name=$(basename "${{ inputs.contracts_repo }}") - rev=$(jq -r --arg pkg "$repo_name" \ + raw_rev=$(jq -r --arg pkg "$repo_name" \ '(.dependencies // {}) + (.devDependencies // {}) | .[$pkg] // ""' \ "$pkgjson") - if [ -z "$rev" ]; then - echo "::warning::Package '$repo_name' not found in package.json deps — falling back to HEAD." - echo "rev=HEAD" >> "$GITHUB_OUTPUT" + if [ -z "$raw_rev" ]; then + echo "::warning::Package '$repo_name' not found in package.json deps — falling back to default branch." + echo "rev=" >> "$GITHUB_OUTPUT" echo "rev_display=HEAD (package-json key not found)" >> "$GITHUB_OUTPUT" else - echo "rev=$rev" >> "$GITHUB_OUTPUT" - echo "rev_display=$rev (from package.json)" >> "$GITHUB_OUTPUT" + # Normalise the package spec value to a checkout-safe ref: + # git+https://github.com/owner/repo#abc123 → abc123 + # github:owner/repo#abc123 → abc123 + # owner/repo#abc123 → abc123 + # ^1.2.3 / ~1.2.3 / 1.2.3 / v1.2.3 → v1.2.3 (strip leading ^/~) + # empty fragment or no # separator → fall back to default branch + if echo "$raw_rev" | grep -q '#'; then + # Extract fragment after the last '#' + rev="${raw_rev##*#}" + elif echo "$raw_rev" | grep -qE '^[\^~]?[0-9]+\.[0-9]+\.[0-9]+'; then + # Plain SemVer — normalise to vX.Y.Z tag (strip leading ^/~) + rev="v$(echo "$raw_rev" | sed 's/^[^0-9]*//')" + else + # Unknown spec format — warn and fall back to default branch + echo "::warning::Cannot parse checkout ref from package.json value '$raw_rev' — falling back to default branch. Use contracts_rev_source=contracts-rev for an explicit SHA pin." + rev="" + fi + if [ -n "$rev" ]; then + echo "rev=$rev" >> "$GITHUB_OUTPUT" + echo "rev_display=$rev (from package.json)" >> "$GITHUB_OUTPUT" + else + echo "rev=" >> "$GITHUB_OUTPUT" + echo "rev_display=HEAD (package-json parse fallback)" >> "$GITHUB_OUTPUT" + fi fi ;; *) @@ -189,8 +224,16 @@ jobs: uses: actions/checkout@v5 with: repository: ${{ inputs.contracts_repo }} + # Empty string here (contracts_rev_source=head) means actions/checkout + # uses the contracts repo's default branch — correct behavior. ref: ${{ steps.resolve_rev.outputs.rev }} path: contracts + # contracts_read_token must be wired for private contracts repos in any + # org (including same org — GITHUB_TOKEN is scoped to the caller repo + # and cannot read other repos, even in the same org, unless they are + # public). For public contracts repos, github.token works as fallback. + # If contracts_read_token is absent and contracts_repo is private, the + # checkout step will fail with HTTP 404 / "Repository not found". token: ${{ secrets.contracts_read_token || github.token }} fetch-depth: 1 From 2afe0ff3ce513d46012d4bb29065d4e46a1096d9 Mon Sep 17 00:00:00 2001 From: topcoder1 Date: Fri, 22 May 2026 09:44:38 -0700 Subject: [PATCH 3/5] fix(openapi-types-drift): address codex round-2 findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- .github/workflows/openapi-types-drift.yml | 91 +++++++++++++++++------ 1 file changed, 70 insertions(+), 21 deletions(-) diff --git a/.github/workflows/openapi-types-drift.yml b/.github/workflows/openapi-types-drift.yml index ff1a2f6..0743b94 100644 --- a/.github/workflows/openapi-types-drift.yml +++ b/.github/workflows/openapi-types-drift.yml @@ -96,9 +96,11 @@ on: secrets: contracts_read_token: description: >- - Optional PAT or token used to clone a private contracts repo. - When unset, the workflow uses the built-in GITHUB_TOKEN for same-org - repos or attempts an unauthenticated clone for public repos. + PAT or token used to clone the contracts repo. + Required for ANY private contracts repo (the built-in GITHUB_TOKEN + is scoped to the caller repository only — it cannot read other + repositories, including same-org private ones). For public contracts + repos the fallback to GITHUB_TOKEN is safe. Required scopes: contents:read on the contracts repo. required: false @@ -152,17 +154,48 @@ jobs: echo "::error::contracts_rev_source=go-mod but go.mod not found." exit 1 fi - # Go pseudo-versions have the form: v0.0.0-YYYYMMDDHHMMSS-XXXXXXXXXXXX - # where the last 12 hex chars are the commit SHA prefix. Plain `require` - # lines with a SemVer tag also resolve correctly via the tag name. - # Extract the 12-char SHA suffix from pseudo-versions first; fall back to - # a plain SemVer tag on the same line if no pseudo-version SHA found. - rev=$(grep -E '(require|replace).*${{ inputs.contracts_repo }}' "$gomod" \ - | sed -n 's/.*-\([0-9a-f]\{12\}\)$/\1/p' | head -1 || true) - if [ -z "$rev" ]; then - # No pseudo-version SHA — try a plain tag (e.g. v1.2.3) - rev=$(grep -E '(require|replace).*${{ inputs.contracts_repo }}' "$gomod" \ - | grep -oE 'v[0-9]+\.[0-9]+\.[0-9]+[^ ]*' | head -1 || true) + # go.mod has two forms for module pinning: + # + # 1. Single-line: require example.com/foo v1.2.3 + # 2. Multi-line block: + # require ( + # example.com/foo v1.2.3 + # ) + # + # In the block form the line containing the module path does NOT + # have the word "require" on it. grep for the module path directly + # rather than for "require" + path on the same line. + # + # The version field on the matched line is either: + # v0.0.0-YYYYMMDDHHMMSS-XXXXXXXXXXXX (pseudo-version / commit SHA) + # v1.2.3[-suffix] (tagged release) + # + # For pseudo-versions we extract the full version string and pass it + # to actions/checkout as a tag lookup — Go's module proxy creates + # synthetic v0.0.0-... tags so checkout can resolve them. Alternatively + # we extract the 12-char SHA suffix for a direct commit checkout. + # Using the full pseudo-version string is safer because it round-trips + # through Go module tooling. + module_path=$(basename "${{ inputs.contracts_repo }}") + 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) + fi + if [ -n "$matched_line" ]; then + # Extract version token (last whitespace-separated word) + version_token=$(echo "$matched_line" | awk '{print $NF}') + # Pseudo-version: extract 12-char SHA suffix for direct commit checkout + sha_suffix=$(echo "$version_token" | sed -n 's/.*-\([0-9a-f]\{12\}\)$/\1/p') + if [ -n "$sha_suffix" ]; then + rev="$sha_suffix" + elif echo "$version_token" | grep -qE '^v[0-9]+\.[0-9]+\.[0-9]+'; then + rev="$version_token" + else + rev="" + fi + else + rev="" fi if [ -z "$rev" ]; then echo "::warning::Could not find a rev for '${{ inputs.contracts_repo }}' in go.mod — falling back to default branch." @@ -188,15 +221,31 @@ jobs: echo "rev=" >> "$GITHUB_OUTPUT" echo "rev_display=HEAD (package-json key not found)" >> "$GITHUB_OUTPUT" else - # Normalise the package spec value to a checkout-safe ref: - # git+https://github.com/owner/repo#abc123 → abc123 - # github:owner/repo#abc123 → abc123 - # owner/repo#abc123 → abc123 - # ^1.2.3 / ~1.2.3 / 1.2.3 / v1.2.3 → v1.2.3 (strip leading ^/~) - # empty fragment or no # separator → fall back to default branch + # Normalise the package spec value to a checkout-safe ref. + # actions/checkout only resolves a hex string as a commit SHA when it + # is exactly 40 characters; shorter strings are looked up as branch/tag + # names. For abbreviated commit SHAs in git+URL / github: fragments, + # recommend upgrading to a full 40-char SHA or using contracts-rev. + # + # Supported forms: + # git+https://github.com/owner/repo#<40-char-SHA> → SHA + # github:owner/repo#<40-char-SHA> → SHA + # owner/repo#<40-char-SHA> → SHA + # ^1.2.3 / ~1.2.3 / 1.2.3 / v1.2.3 → v1.2.3 (tag) + # Short fragment (#abc123 < 40 chars) → warn, fall back if echo "$raw_rev" | grep -q '#'; then # Extract fragment after the last '#' - rev="${raw_rev##*#}" + fragment="${raw_rev##*#}" + # Only treat as a checkout ref when it is a full 40-char SHA or + # a tag-looking string. Short fragments are ambiguous. + if echo "$fragment" | grep -qE '^[0-9a-f]{40}$'; then + rev="$fragment" + elif echo "$fragment" | grep -qE '^v[0-9]+\.[0-9]+'; then + rev="$fragment" + else + echo "::warning::package.json fragment '#$fragment' is not a 40-char SHA or tag — cannot safely resolve for checkout. Use a full 40-char SHA or switch to contracts_rev_source=contracts-rev." + rev="" + fi elif echo "$raw_rev" | grep -qE '^[\^~]?[0-9]+\.[0-9]+\.[0-9]+'; then # Plain SemVer — normalise to vX.Y.Z tag (strip leading ^/~) rev="v$(echo "$raw_rev" | sed 's/^[^0-9]*//')" From d0732765886cc56923452857ea14ce5817f01a92 Mon Sep 17 00:00:00 2001 From: topcoder1 Date: Fri, 22 May 2026 09:48:00 -0700 Subject: [PATCH 4/5] fix(openapi-types-drift): address codex round-3 findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- .github/workflows/openapi-types-drift.yml | 29 ++++++++++++++++++----- README.md | 2 +- 2 files changed, 24 insertions(+), 7 deletions(-) diff --git a/.github/workflows/openapi-types-drift.yml b/.github/workflows/openapi-types-drift.yml index 0743b94..4b75435 100644 --- a/.github/workflows/openapi-types-drift.yml +++ b/.github/workflows/openapi-types-drift.yml @@ -183,12 +183,21 @@ jobs: matched_line=$(grep -E "${{ inputs.contracts_repo }}" "$gomod" | grep -v '^//' | head -1 || true) fi if [ -n "$matched_line" ]; then - # Extract version token (last whitespace-separated word) - version_token=$(echo "$matched_line" | awk '{print $NF}') + # Strip inline comments (// indirect, // comments) then take the + # last whitespace-separated word as the version token. + version_token=$(echo "$matched_line" | sed 's/\/\/.*$//' | awk '{print $NF}') # Pseudo-version: extract 12-char SHA suffix for direct commit checkout sha_suffix=$(echo "$version_token" | sed -n 's/.*-\([0-9a-f]\{12\}\)$/\1/p') if [ -n "$sha_suffix" ]; then - rev="$sha_suffix" + # actions/checkout requires a 40-char full SHA to resolve a + # commit directly. The 12-char suffix from a pseudo-version is + # not sufficient. Use the full pseudo-version string instead — + # the Go module proxy creates synthetic tags of this form that + # the contracts repo's git history can resolve. If the contracts + # repo is not a Go module proxy consumer (e.g. it is a plain + # npm-only repo), this tag lookup will fail; in that case switch + # to contracts_rev_source=contracts-rev with a full 40-char SHA. + rev="$version_token" elif echo "$version_token" | grep -qE '^v[0-9]+\.[0-9]+\.[0-9]+'; then rev="$version_token" else @@ -246,9 +255,17 @@ jobs: echo "::warning::package.json fragment '#$fragment' is not a 40-char SHA or tag — cannot safely resolve for checkout. Use a full 40-char SHA or switch to contracts_rev_source=contracts-rev." rev="" fi - elif echo "$raw_rev" | grep -qE '^[\^~]?[0-9]+\.[0-9]+\.[0-9]+'; then - # Plain SemVer — normalise to vX.Y.Z tag (strip leading ^/~) - rev="v$(echo "$raw_rev" | sed 's/^[^0-9]*//')" + elif echo "$raw_rev" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+'; then + # Exact SemVer without operator — normalise to vX.Y.Z tag + rev="v${raw_rev}" + elif echo "$raw_rev" | grep -qE '^v[0-9]+\.[0-9]+\.[0-9]+[^-]*$'; then + # Already v-prefixed exact tag (no range operator) + rev="$raw_rev" + elif echo "$raw_rev" | grep -qE '^[\^~]'; then + # Semver range — cannot determine exact pinned version without + # reading the lockfile. Warn and fall back; use contracts-rev. + echo "::warning::package.json contains semver range '$raw_rev' — cannot determine exact contracts revision without reading the lockfile. Switch to contracts_rev_source=contracts-rev with a full 40-char SHA for a precise drift check." + rev="" else # Unknown spec format — warn and fall back to default branch echo "::warning::Cannot parse checkout ref from package.json value '$raw_rev' — falling back to default branch. Use contracts_rev_source=contracts-rev for an explicit SHA pin." diff --git a/README.md b/README.md index d9915bd..2cd0b99 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,7 @@ Detects drift between a committed generated-types file (e.g. `src/api/types.gen. **Secrets:** -- `contracts_read_token` (optional) — PAT for private contracts repos in a different org; built-in `GITHUB_TOKEN` suffices for same-org private repos +- `contracts_read_token` — PAT required for ANY private contracts repo (the built-in `GITHUB_TOKEN` is scoped to the caller repo only; it cannot read other private repos, even in the same org). Not needed for public contracts repos. **On drift:** fails the check and posts a sticky PR comment with the first 50 lines of the diff and regen instructions. Removes the comment automatically when the PR is updated and drift is gone. From 1fb3b468593d03fdf786f32d6f52ff5efd7e6d59 Mon Sep 17 00:00:00 2001 From: topcoder1 Date: Fri, 22 May 2026 17:01:27 -0700 Subject: [PATCH 5/5] fix(openapi-types-drift): address codex round-4 findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- .github/workflows/openapi-types-drift.yml | 44 ++++++++++++++++------- 1 file changed, 32 insertions(+), 12 deletions(-) diff --git a/.github/workflows/openapi-types-drift.yml b/.github/workflows/openapi-types-drift.yml index 4b75435..b58b450 100644 --- a/.github/workflows/openapi-types-drift.yml +++ b/.github/workflows/openapi-types-drift.yml @@ -66,6 +66,17 @@ on: required: false type: string default: ".contracts-rev" + contracts_package_name: + description: >- + Package name to look up in package.json when contracts_rev_source= + package-json. Defaults to the basename of contracts_repo, which works + for unscoped packages (e.g. "asm-contracts-v2" from + "topcoder1/asm-contracts-v2"). Set explicitly for scoped packages + (e.g. "@topcoder1/asm-contracts-v2") or when the npm package name + differs from the repo name. + required: false + type: string + default: "" contracts_gen_cmd: description: >- Command to regenerate types, run from inside the contracts repo. @@ -189,15 +200,17 @@ jobs: # Pseudo-version: extract 12-char SHA suffix for direct commit checkout sha_suffix=$(echo "$version_token" | sed -n 's/.*-\([0-9a-f]\{12\}\)$/\1/p') if [ -n "$sha_suffix" ]; then - # actions/checkout requires a 40-char full SHA to resolve a - # commit directly. The 12-char suffix from a pseudo-version is - # not sufficient. Use the full pseudo-version string instead — - # the Go module proxy creates synthetic tags of this form that - # the contracts repo's git history can resolve. If the contracts - # repo is not a Go module proxy consumer (e.g. it is a plain - # npm-only repo), this tag lookup will fail; in that case switch - # to contracts_rev_source=contracts-rev with a full 40-char SHA. - rev="$version_token" + # Go pseudo-versions (v0.0.0-YYYYMMDDHHMMSS-XXXXXXXXXXXX) are + # NOT real git tags in the contracts repo — they exist only in + # the Go module proxy index. actions/checkout will fail when + # given a pseudo-version string unless the contracts repo has an + # identically-named tag (unlikely). The cleanest fix is to emit + # a warning and fall back: the drift check runs against contracts + # HEAD, which is advisory-quality but avoids a hard checkout + # failure. For a precise check, switch to + # contracts_rev_source=contracts-rev with a full 40-char SHA. + echo "::warning::go.mod contains pseudo-version '$version_token'. Pseudo-versions are not git tags and cannot be resolved by actions/checkout. Falling back to contracts default branch. Switch to contracts_rev_source=contracts-rev for a precise pin." + rev="" elif echo "$version_token" | grep -qE '^v[0-9]+\.[0-9]+\.[0-9]+'; then rev="$version_token" else @@ -221,12 +234,19 @@ jobs: echo "::error::contracts_rev_source=package-json but package.json not found." exit 1 fi - repo_name=$(basename "${{ inputs.contracts_repo }}") - raw_rev=$(jq -r --arg pkg "$repo_name" \ + # Use the explicit package name input if provided; fall back to the + # repo basename. Callers with scoped packages (e.g. + # @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=$(basename "${{ inputs.contracts_repo }}") + fi + raw_rev=$(jq -r --arg pkg "$pkg_name" \ '(.dependencies // {}) + (.devDependencies // {}) | .[$pkg] // ""' \ "$pkgjson") if [ -z "$raw_rev" ]; then - echo "::warning::Package '$repo_name' not found in package.json deps — falling back to default branch." + echo "::warning::Package '$pkg_name' not found in package.json deps — falling back to default branch. If the npm package name differs from the repo basename, set the contracts_package_name input." echo "rev=" >> "$GITHUB_OUTPUT" echo "rev_display=HEAD (package-json key not found)" >> "$GITHUB_OUTPUT" else