Skip to content

Add language-agnostic emitter-diff tool #16

Add language-agnostic emitter-diff tool

Add language-agnostic emitter-diff tool #16

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 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).
# 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 the "Fail on tool error" step below).
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" \
| 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)."