Add language-agnostic emitter-diff tool #19
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. | |
| # | |
| # This check is informational: it always passes unless the tool hits a real | |
| # tool/build error. A generated-output diff does not fail the PR — it is only | |
| # reported (job summary + PR comment + HTML artifact) for reviewers to eyeball. | |
| # | |
| # 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: | |
| 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 with plain `node` (Node 24 runs TypeScript natively) so the tool's | |
| # real exit code propagates. No `--fail-on-diff`: this check is | |
| # informational and never fails on a diff. A real tool/build error still | |
| # surfaces via a non-zero exit code (checked in "Fail on tool error"). | |
| node 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" \ | |
| | 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 }} | |
| RUNNER_TEMP: ${{ runner.temp }} | |
| run: | | |
| { | |
| echo "## Emitter diff" | |
| echo "" | |
| echo "Baseline (merge-base): \`$BASELINE_SHA\` vs current checkout." | |
| 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 }} | |
| 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; | |
| // 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 hasChanges = patch.trim().length > 0; | |
| 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") { | |
| // 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 (!hasChanges) { | |
| 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`; | |
| } | |
| } | |
| body += `\n_Generated by \`eng/emitter-diff\`. This check is informational and does not block the PR._`; | |
| 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: Fail on tool error | |
| if: always() | |
| env: | |
| STATUS: ${{ steps.diff.outputs.status }} | |
| run: | | |
| # Informational check: a generated-output diff does NOT fail the PR | |
| # (the tool is run without --fail-on-diff, so a diff still exits 0). | |
| # Only a real tool/build error (non-zero exit) fails the job. | |
| if [ "$STATUS" != "0" ]; then | |
| echo "::error::emitter-diff failed (exit $STATUS). See the log and the emitter-diff-html artifact." | |
| exit 1 | |
| fi | |
| echo "emitter-diff ran successfully (informational; diffs are reported, not enforced)." |