Add language-agnostic emitter-diff tool #15
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: "python / emitter diff" | |
| # Generates code with the current checkout's emitter and with the emitter as of | |
| # the base-branch commit this PR is based on (the merge-base), then diffs the two. | |
| # The rendered HTML diff is uploaded as a per-PR artifact and a sticky PR comment | |
| # summarizes the change with a link to download it, so reviewers can see exactly | |
| # how an emitter change affects generated SDKs. Powered by the language-agnostic | |
| # eng/emitter-diff tool. | |
| # | |
| # The check fails while generated output differs from the baseline UNTIL a | |
| # maintainer adds the `emitter-diff-approved` label to the PR. The label (not a | |
| # committed SHA) is the approval token, so it survives squash-merge / rebase / | |
| # force-push, and the check re-runs automatically when the label is added or | |
| # removed. | |
| # | |
| # Python's generator uses a native two-phase pipeline (TypeSpec emits YAML, then | |
| # a batched Python subprocess writes the .py files) with a venv co-located with | |
| # each emitter version. So this workflow builds + sets up a venv for both the | |
| # head checkout and a worktree of the baseline commit before diffing. | |
| on: | |
| pull_request: | |
| types: [opened, synchronize, reopened, labeled, unlabeled] | |
| branches: | |
| - main | |
| - release/* | |
| paths: | |
| - "packages/http-client-python/**" | |
| - "eng/emitter-diff/**" | |
| - ".github/workflows/ci-emitter-diff-python.yml" | |
| workflow_dispatch: | |
| inputs: | |
| baseline: | |
| description: "Baseline emitter ref (npm version, local path, or github ref). Defaults to the PR's merge-base with its base branch." | |
| required: false | |
| default: "" | |
| permissions: | |
| contents: read | |
| pull-requests: write | |
| concurrency: | |
| group: ${{ github.workflow }}-${{ github.ref }} | |
| cancel-in-progress: true | |
| jobs: | |
| emitter-diff: | |
| name: "Generate & Diff" | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v6 | |
| with: | |
| fetch-depth: 0 | |
| - uses: ./.github/actions/setup | |
| - uses: actions/setup-python@v5 | |
| with: | |
| python-version: "3.12" | |
| - name: Install repo dependencies (emitter-diff tool) | |
| run: pnpm install | |
| - name: Build + venv for head emitter | |
| working-directory: packages/http-client-python | |
| run: | | |
| npm ci | |
| npm run build | |
| npm run install | |
| - name: Determine baseline | |
| id: baseline | |
| # The dispatch input is untrusted; pass everything through `env` and | |
| # reference only shell variables so nothing is interpolated into the | |
| # script body (GitHub Actions expression injection). | |
| env: | |
| BASELINE_INPUT: ${{ github.event.inputs.baseline || '' }} | |
| BASE_REF: ${{ github.event.pull_request.base.ref || github.event.repository.default_branch }} | |
| RUNNER_TEMP: ${{ runner.temp }} | |
| run: | | |
| input="$BASELINE_INPUT" | |
| if [ -z "$input" ]; then | |
| # Baseline = the base-branch commit this PR is based on (merge-base). | |
| # It is always a real commit on the target branch, so it survives | |
| # squash-merge / rebase / force-push (unlike a pinned branch SHA). | |
| git fetch --no-tags origin "$BASE_REF" | |
| base_sha="$(git merge-base FETCH_HEAD HEAD)" | |
| if [ -z "$base_sha" ]; then | |
| echo "::error::Could not determine a merge-base with $BASE_REF." | |
| exit 1 | |
| fi | |
| echo "Baseline (merge-base with $BASE_REF): $base_sha" | |
| git worktree add "$RUNNER_TEMP/baseline" "$base_sha" | |
| (cd "$RUNNER_TEMP/baseline/packages/http-client-python" && npm ci && npm run build && npm run install) | |
| input="local:$RUNNER_TEMP/baseline/packages/http-client-python" | |
| echo "sha=$base_sha" >> "$GITHUB_OUTPUT" | |
| fi | |
| echo "ref=$input" >> "$GITHUB_OUTPUT" | |
| echo "Baseline: $input" | |
| - name: Run emitter diff | |
| id: diff | |
| working-directory: eng/emitter-diff | |
| # `BASELINE_REF` derives from the untrusted dispatch input, so keep it in | |
| # `env` and reference it as "$BASELINE_REF" rather than interpolating it. | |
| env: | |
| BASELINE_REF: ${{ steps.baseline.outputs.ref }} | |
| RUNNER_TEMP: ${{ runner.temp }} | |
| run: | | |
| set +e | |
| # Run via plain (non-recursive) `pnpm exec` so the tool's real exit code | |
| # propagates. `pnpm --filter <pkg> exec` collapses any non-zero child exit | |
| # into pnpm's own generic exit 1 (ERR_PNPM_RECURSIVE_EXEC_FIRST_FAIL), | |
| # which would mask the "diff present" code (2) as a hard error (1). | |
| pnpm exec tsx src/cli.ts \ | |
| --emitter python \ | |
| --baseline "$BASELINE_REF" \ | |
| --work-dir "$RUNNER_TEMP/emitter-diff" \ | |
| --html "$RUNNER_TEMP/emitter-diff.html" \ | |
| --patch "$RUNNER_TEMP/emitter-diff.patch" \ | |
| --fail-on-diff \ | |
| | tee "$RUNNER_TEMP/emitter-diff.log" | |
| echo "status=${PIPESTATUS[0]}" >> "$GITHUB_OUTPUT" | |
| - name: Write job summary | |
| if: always() | |
| env: | |
| BASELINE_SHA: ${{ steps.baseline.outputs.sha }} | |
| STATUS: ${{ steps.diff.outputs.status }} | |
| APPROVED: ${{ contains(github.event.pull_request.labels.*.name, 'emitter-diff-approved') }} | |
| RUNNER_TEMP: ${{ runner.temp }} | |
| run: | | |
| { | |
| echo "## Emitter diff" | |
| echo "" | |
| echo "Baseline (merge-base): \`$BASELINE_SHA\` vs current checkout." | |
| echo "" | |
| if [ "$STATUS" = "2" ]; then | |
| if [ "$APPROVED" = "true" ]; then | |
| echo "**Generated output changed** — approved via the \`emitter-diff-approved\` label." | |
| else | |
| echo "**Generated output changed vs the baseline.**" | |
| echo "" | |
| echo "If this change is intended, add the \`emitter-diff-approved\` label to this PR" | |
| echo "to accept it." | |
| fi | |
| elif [ "$STATUS" = "0" ]; then | |
| echo "Generated output matches the baseline." | |
| fi | |
| echo "" | |
| echo '```' | |
| grep -E "Diff summary:" "$RUNNER_TEMP/emitter-diff.log" || echo "No summary captured." | |
| echo '```' | |
| echo "" | |
| echo "Download the **emitter-diff-html** artifact for the full rendered diff." | |
| } >> "$GITHUB_STEP_SUMMARY" | |
| - name: Upload HTML diff | |
| if: always() | |
| uses: actions/upload-artifact@v7 | |
| with: | |
| name: emitter-diff-html | |
| path: ${{ runner.temp }}/emitter-diff.html | |
| if-no-files-found: ignore | |
| retention-days: 7 | |
| - name: Comment on PR | |
| if: always() && github.event_name == 'pull_request' | |
| continue-on-error: true | |
| uses: actions/github-script@v7 | |
| env: | |
| BASELINE: ${{ steps.baseline.outputs.sha }} | |
| STATUS: ${{ steps.diff.outputs.status }} | |
| APPROVED: ${{ contains(github.event.pull_request.labels.*.name, 'emitter-diff-approved') }} | |
| PATCH_FILE: ${{ runner.temp }}/emitter-diff.patch | |
| HTML_FILE: ${{ runner.temp }}/emitter-diff.html | |
| with: | |
| script: | | |
| const fs = require("fs"); | |
| const marker = "<!-- emitter-diff-python -->"; | |
| const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`; | |
| const status = process.env.STATUS; | |
| const approved = process.env.APPROVED === "true"; | |
| // Parse the unified patch directly so the numbers match the tool exactly. | |
| let patch = ""; | |
| try { patch = fs.readFileSync(process.env.PATCH_FILE, "utf8"); } catch {} | |
| const lines = patch.split("\n"); | |
| const files = []; | |
| let insertions = 0, deletions = 0; | |
| for (const line of lines) { | |
| const m = line.match(/^diff --git a\/(.+?) b\/(.+)$/); | |
| if (m) files.push(m[2]); | |
| else if (line.startsWith("+") && !line.startsWith("+++")) insertions++; | |
| else if (line.startsWith("-") && !line.startsWith("---")) deletions++; | |
| } | |
| const htmlExists = fs.existsSync(process.env.HTML_FILE); | |
| let body = `${marker}\n## Python emitter diff\n\n` + | |
| `Baseline \`${process.env.BASELINE}\` (merge-base) vs this PR's checkout.\n\n`; | |
| if (status !== "0" && status !== "2") { | |
| // Hard error (build/venv/generate threw) — not a diff. Don't claim "no changes". | |
| body += `**emitter-diff failed to run** (exit \`${status}\`). This is a tool/build ` + | |
| `error, not a generated-output diff. See the ` + | |
| `[workflow run](${runUrl}) logs` + | |
| (htmlExists ? ` and the **emitter-diff-html** artifact` : "") + `.\n`; | |
| } else if (status === "0") { | |
| body += `**No changes** — generated output matches the baseline.\n`; | |
| } else { | |
| body += `**${files.length} file(s) changed** · +${insertions} / -${deletions}\n\n`; | |
| if (htmlExists) { | |
| body += `Download the **emitter-diff-html** artifact from the ` + | |
| `[workflow run](${runUrl}) for the full side-by-side rendered diff.\n\n`; | |
| } | |
| if (approved) { | |
| body += `This change is **approved** via the \`emitter-diff-approved\` label.\n`; | |
| } else { | |
| body += `**This check fails until the change is approved.** If the new output is ` + | |
| `intended, add the \`emitter-diff-approved\` label to this PR to accept it.\n`; | |
| } | |
| } | |
| body += `\n_Generated by \`eng/emitter-diff\`._`; | |
| const { owner, repo } = context.repo; | |
| const issue_number = context.issue.number; | |
| const { data: comments } = await github.rest.issues.listComments({ owner, repo, issue_number }); | |
| const existing = comments.find((c) => c.body && c.body.includes(marker)); | |
| if (existing) { | |
| await github.rest.issues.updateComment({ owner, repo, comment_id: existing.id, body }); | |
| } else { | |
| await github.rest.issues.createComment({ owner, repo, issue_number, body }); | |
| } | |
| - name: Enforce approval | |
| if: always() | |
| env: | |
| STATUS: ${{ steps.diff.outputs.status }} | |
| APPROVED: ${{ contains(github.event.pull_request.labels.*.name, 'emitter-diff-approved') }} | |
| BASELINE_SHA: ${{ steps.baseline.outputs.sha }} | |
| run: | | |
| # Hard tool/build failure (not a diff) always fails. | |
| if [ "$STATUS" != "0" ] && [ "$STATUS" != "2" ]; then | |
| echo "::error::emitter-diff failed (exit $STATUS). See the log and artifact." | |
| exit 1 | |
| fi | |
| # Only PRs carry the approval label; manual/dispatch runs are informational. | |
| if [ "$GITHUB_EVENT_NAME" != "pull_request" ]; then | |
| echo "Not a pull_request event; skipping the approval gate." | |
| exit 0 | |
| fi | |
| if [ "$STATUS" = "2" ]; then | |
| if [ "$APPROVED" = "true" ]; then | |
| echo "Generated output differs from the baseline ($BASELINE_SHA), but the PR carries" | |
| echo "the 'emitter-diff-approved' label. Passing." | |
| exit 0 | |
| fi | |
| echo "::error::Generated output differs from the baseline ($BASELINE_SHA)." | |
| echo "Review the emitter-diff-html artifact. If the change is intended, add the" | |
| echo "'emitter-diff-approved' label to this PR to accept it." | |
| exit 1 | |
| fi | |
| echo "Generated output matches the baseline." |