TypeSpec Python Regenerate Tests #8
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: TypeSpec Python Regenerate Tests | |
| on: | |
| # Trigger when eng/emitter-package.json is updated on main (uses default microsoft/typespec@main) | |
| push: | |
| branches: [main] | |
| paths: | |
| - "eng/emitter-package.json" | |
| # Run daily at 22:00 UTC against microsoft/typespec@main | |
| schedule: | |
| - cron: "0 22 * * *" | |
| # Allow manual triggering | |
| workflow_dispatch: | |
| inputs: | |
| typespec_ref: | |
| description: "Either 'main' (microsoft/typespec@main) or a microsoft/typespec pull request URL (e.g. https://github.com/microsoft/typespec/pull/1234). The PR's head repo + SHA will be checked out." | |
| required: false | |
| default: "main" | |
| permissions: | |
| contents: write | |
| pull-requests: write | |
| issues: write | |
| # Note: with cancel-in-progress, a newer run can cancel an older one after it | |
| # has force-pushed the branch but before it finishes updating the tracking | |
| # issue. The newer run will redo the issue update, so the worst case is a | |
| # brief stale issue body that is immediately refreshed. | |
| concurrency: | |
| group: ${{ github.workflow }} | |
| cancel-in-progress: true | |
| jobs: | |
| regenerate: | |
| name: "Regenerate TypeSpec Python tests" | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Checkout azure-sdk-for-python | |
| # SHA corresponds to actions/checkout@v6 | |
| uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd | |
| with: | |
| fetch-depth: 0 | |
| - name: Resolve TypeSpec repo/ref | |
| id: typespec-info | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| run: | | |
| set -euo pipefail | |
| INPUT="${{ github.event.inputs.typespec_ref || 'main' }}" | |
| # Default: microsoft/typespec @ main | |
| REPO="microsoft/typespec" | |
| REF="main" | |
| DISPLAY_REF="main" | |
| REF_URL="https://github.com/${REPO}/tree/main" | |
| PR_NUMBER="" | |
| # Accept a microsoft/typespec PR URL and resolve it to head repo + SHA. | |
| # Example: https://github.com/microsoft/typespec/pull/1234 | |
| if [[ "$INPUT" =~ ^https://github\.com/([^/]+)/([^/]+)/pull/([0-9]+)/?$ ]]; then | |
| PR_OWNER="${BASH_REMATCH[1]}" | |
| PR_REPO_NAME="${BASH_REMATCH[2]}" | |
| PR_NUMBER="${BASH_REMATCH[3]}" | |
| if [ "$PR_OWNER/$PR_REPO_NAME" != "microsoft/typespec" ]; then | |
| echo "::error::Only pull request URLs from microsoft/typespec are accepted (got ${PR_OWNER}/${PR_REPO_NAME})." | |
| exit 1 | |
| fi | |
| echo "Resolving PR #${PR_NUMBER} from ${PR_OWNER}/${PR_REPO_NAME}..." | |
| PR_JSON=$(gh pr view "$PR_NUMBER" --repo "${PR_OWNER}/${PR_REPO_NAME}" \ | |
| --json headRefOid,headRepositoryOwner,headRepository) | |
| HEAD_SHA=$(echo "$PR_JSON" | jq -r '.headRefOid') | |
| HEAD_OWNER=$(echo "$PR_JSON" | jq -r '.headRepositoryOwner.login') | |
| HEAD_REPO_NAME=$(echo "$PR_JSON" | jq -r '.headRepository.name') | |
| REPO="${HEAD_OWNER}/${HEAD_REPO_NAME}" | |
| REF="${HEAD_SHA}" | |
| DISPLAY_REF="PR #${PR_NUMBER} @ ${HEAD_SHA:0:7}" | |
| REF_URL="${INPUT}" | |
| elif [ "$INPUT" != "main" ]; then | |
| echo "::error::typespec_ref must be 'main' or a microsoft/typespec pull request URL (got: ${INPUT})." | |
| exit 1 | |
| fi | |
| echo "typespec_repo=$REPO" >> $GITHUB_OUTPUT | |
| echo "typespec_ref=$REF" >> $GITHUB_OUTPUT | |
| echo "typespec_display_ref=$DISPLAY_REF" >> $GITHUB_OUTPUT | |
| echo "typespec_ref_url=$REF_URL" >> $GITHUB_OUTPUT | |
| echo "typespec_pr_number=$PR_NUMBER" >> $GITHUB_OUTPUT | |
| echo "::notice::Regenerating from ${REPO}@${DISPLAY_REF}" | |
| - name: Checkout microsoft/typespec | |
| # SHA corresponds to actions/checkout@v6 | |
| # Checkout to "_typespec" (not "typespec") to avoid the workspace path | |
| # "azure-sdk-for-python" causing spec.includes("azure") to match all specs | |
| # in regenerate.ts, which breaks unbranded package name detection | |
| uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd | |
| with: | |
| repository: ${{ steps.typespec-info.outputs.typespec_repo }} | |
| ref: ${{ steps.typespec-info.outputs.typespec_ref }} | |
| path: _typespec | |
| fetch-depth: 0 | |
| - name: Setup Node.js | |
| # SHA corresponds to actions/setup-node@v6 | |
| uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e | |
| with: | |
| node-version: lts/* | |
| - name: Setup Python | |
| # SHA corresponds to actions/setup-python@v5 | |
| uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 | |
| with: | |
| python-version: "3.12" | |
| - name: Build http-client-python | |
| working-directory: _typespec/packages/http-client-python | |
| run: | | |
| npm install --ignore-scripts | |
| npm run build | |
| - name: Prepare Python environment | |
| working-directory: _typespec/packages/http-client-python | |
| run: | | |
| npm run install | |
| npm run prepare | |
| - name: Regenerate tests | |
| working-directory: _typespec/packages/http-client-python | |
| run: | | |
| npm run regenerate | |
| - name: Copy regenerated tests | |
| run: | | |
| set -euo pipefail | |
| TARGET="eng/tools/emitter/gen" | |
| rm -rf "$TARGET/azure" "$TARGET/unbranded" | |
| mkdir -p "$TARGET" | |
| cp -r "_typespec/packages/http-client-python/tests/generated/azure" "$TARGET/azure" | |
| cp -r "_typespec/packages/http-client-python/tests/generated/unbranded" "$TARGET/unbranded" | |
| - name: Clean up typespec checkout | |
| run: rm -rf "_typespec" | |
| - name: Apply README template to generated test packages | |
| run: | | |
| set -euo pipefail | |
| TARGET="eng/tools/emitter/gen" | |
| TEMPLATE="$TARGET/template/README.md" | |
| if [ ! -f "$TEMPLATE" ]; then | |
| echo "::error::Template README not found at $TEMPLATE" | |
| exit 1 | |
| fi | |
| # Replace every README.md under gen/ with the template, except: | |
| # - the top-level gen/README.md | |
| # - anything under gen/template/ (the template itself and any | |
| # future siblings) | |
| find "$TARGET" -type f -name README.md \ | |
| ! -path "$TARGET/README.md" \ | |
| ! -path "$TARGET/template/*" \ | |
| -print -exec cp -f "$TEMPLATE" {} \; | |
| - name: Commit and push changes | |
| id: push-changes | |
| run: | | |
| set -euo pipefail | |
| PR_NUMBER="${{ steps.typespec-info.outputs.typespec_pr_number }}" | |
| if [ -n "$PR_NUMBER" ]; then | |
| SOURCE_LABEL="microsoft/typespec PR #${PR_NUMBER}" | |
| BRANCH="auto/typespec-python-regenerate-${PR_NUMBER}" | |
| else | |
| SOURCE_LABEL="microsoft/typespec@main" | |
| BRANCH="auto/typespec-python-regenerate" | |
| fi | |
| git config user.name "github-actions[bot]" | |
| git config user.email "github-actions[bot]@users.noreply.github.com" | |
| # First, check whether regenerated content actually differs from what | |
| # is already on disk (relative to the workflow's HEAD). | |
| git add eng/tools/emitter/gen/ | |
| if git diff --cached --quiet; then | |
| echo "No changes to commit" | |
| echo "has_changes=false" >> $GITHUB_OUTPUT | |
| exit 0 | |
| fi | |
| # Branch off origin/main (NOT the current HEAD) so the auto branch | |
| # never contains unrelated commits from the branch that triggered | |
| # the workflow. In particular this avoids carrying changes to | |
| # .github/workflows/*, which GITHUB_TOKEN is not allowed to push | |
| # (missing `workflows` permission). | |
| git fetch --no-tags --depth=1 origin main | |
| git checkout -B "$BRANCH" origin/main | |
| # Re-apply just the regenerated gen/ tree on top of origin/main. | |
| # HEAD@{1} is the workflow's original HEAD before the checkout above. | |
| git checkout HEAD@{1} -- eng/tools/emitter/gen | |
| git add eng/tools/emitter/gen/ | |
| # If origin/main already matches the regenerated output, there is | |
| # nothing to push. | |
| if git diff --cached --quiet; then | |
| echo "No changes vs origin/main" | |
| echo "has_changes=false" >> $GITHUB_OUTPUT | |
| exit 0 | |
| fi | |
| git commit -m "[typespec-python] Regenerate tests from ${SOURCE_LABEL}" | |
| git push origin "$BRANCH" --force | |
| echo "has_changes=true" >> $GITHUB_OUTPUT | |
| echo "branch=$BRANCH" >> $GITHUB_OUTPUT | |
| - name: Create or update tracking issue with PR link | |
| id: create-issue | |
| if: steps.push-changes.outputs.has_changes == 'true' | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| run: | | |
| set -euo pipefail | |
| TS_REF_URL="${{ steps.typespec-info.outputs.typespec_ref_url }}" | |
| PR_NUMBER="${{ steps.typespec-info.outputs.typespec_pr_number }}" | |
| BRANCH="${{ steps.push-changes.outputs.branch }}" | |
| REPO="${{ github.repository }}" | |
| SERVER="${{ github.server_url }}" | |
| RUN_URL="${SERVER}/${REPO}/actions/runs/${{ github.run_id }}" | |
| # Use a stable source identifier so retriggering from the same `main` | |
| # or the same PR reuses the existing tracking issue instead of | |
| # creating a duplicate. | |
| if [ -n "$PR_NUMBER" ]; then | |
| SOURCE_LABEL="microsoft/typespec PR #${PR_NUMBER}" | |
| else | |
| SOURCE_LABEL="microsoft/typespec@main" | |
| fi | |
| # Determine assignees. For manual (workflow_dispatch) triggers, | |
| # assign to the user who triggered the run. For automatic triggers | |
| # (push), fall back to the default maintainers. | |
| EVENT_NAME="${{ github.event_name }}" | |
| ACTOR="${{ github.actor }}" | |
| if [ "$EVENT_NAME" = "workflow_dispatch" ] && [ -n "$ACTOR" ]; then | |
| ASSIGNEES="$ACTOR" | |
| CC_LINE="cc @${ACTOR}" | |
| else | |
| ASSIGNEES="iscai-msft,msyyc" | |
| CC_LINE="cc @iscai-msft @msyyc" | |
| fi | |
| TITLE="[typespec-python] Regenerate tests from ${SOURCE_LABEL}" | |
| # Reuse an existing open tracking issue if one exists (matched by | |
| # exact title). We resolve the issue number BEFORE building the PR | |
| # compare URL so we can inject "Fixes #<N>" into the prefilled PR | |
| # body — this way the issue is closed automatically when the PR is | |
| # merged. | |
| # | |
| # We list by label rather than `--search`, because GitHub's search | |
| # tokenizer strips characters like `[`, `]`, `@`, `#` and `/`, so a | |
| # search query over our title can be ambiguous. Listing by label | |
| # plus an exact jq match is both faster and unambiguous. | |
| EXISTING_ISSUE=$(gh issue list --state open --label typespec-python \ | |
| --limit 100 --json number,title \ | |
| | jq -r --arg title "$TITLE" '.[] | select(.title == $title) | .number' \ | |
| | head -n1 || echo "") | |
| if [ -n "$EXISTING_ISSUE" ]; then | |
| ISSUE_NUMBER="$EXISTING_ISSUE" | |
| echo "Reusing existing tracking issue #$ISSUE_NUMBER" | |
| else | |
| echo "Creating new tracking issue" | |
| # `gh issue create` prints the new issue's URL to stdout; parse the | |
| # trailing number out of it. (`--json`/`--jq` are not supported on | |
| # `gh issue create`.) The body will be filled in below once we | |
| # have the compare URL. | |
| ISSUE_URL=$(gh issue create --title "$TITLE" \ | |
| --body "Tracking issue for TypeSpec Python regeneration. Details will be filled in shortly." \ | |
| --label "typespec-python" \ | |
| --assignee "$ASSIGNEES") | |
| ISSUE_NUMBER="${ISSUE_URL##*/}" | |
| echo "Created issue #$ISSUE_NUMBER ($ISSUE_URL)" | |
| fi | |
| # Check whether an open PR already exists from this branch to main. | |
| # If so, the tracking issue should just point at that PR instead of | |
| # asking the user to create a new one. | |
| EXISTING_PR_JSON=$(gh pr list --state open --head "$BRANCH" --base main \ | |
| --json number,url --limit 1) | |
| EXISTING_PR_URL=$(echo "$EXISTING_PR_JSON" | jq -r '.[0].url // empty') | |
| EXISTING_PR_NUMBER=$(echo "$EXISTING_PR_JSON" | jq -r '.[0].number // empty') | |
| if [ -n "$EXISTING_PR_URL" ]; then | |
| ISSUE_BODY="A pull request already exists for this regeneration. | |
| 👉 [View pull request #${EXISTING_PR_NUMBER}](${EXISTING_PR_URL}) | |
| The branch \`${BRANCH}\` was just updated with the latest regenerated tests; the existing PR will reflect those changes automatically. | |
| Details: | |
| - Source: [${SOURCE_LABEL}](${TS_REF_URL}) | |
| - Branch: [\`${BRANCH}\`](${SERVER}/${REPO}/tree/${BRANCH}) | |
| - Latest workflow run: ${RUN_URL} | |
| ${CC_LINE}" | |
| else | |
| # Build a "compare" URL that opens the PR creation page pre-filled. | |
| # GitHub Actions cannot create PRs directly (org policy), so the | |
| # reviewer just needs to click the link to open the PR. | |
| ISSUE_LINK="${SERVER}/${REPO}/issues/${ISSUE_NUMBER}" | |
| PR_TITLE_ENC=$(jq -rn --arg t "$TITLE" '$t|@uri') | |
| PR_BODY_RAW="Fixes ${ISSUE_LINK} | |
| Source: ${TS_REF_URL} | |
| Automated regeneration of TypeSpec Python generated tests from ${SOURCE_LABEL}. | |
| - Workflow run: ${RUN_URL} | |
| This PR was auto-generated." | |
| PR_BODY_ENC=$(jq -rn --arg b "$PR_BODY_RAW" '$b|@uri') | |
| COMPARE_URL="${SERVER}/${REPO}/compare/main...${BRANCH}?quick_pull=1&title=${PR_TITLE_ENC}&body=${PR_BODY_ENC}" | |
| ISSUE_BODY="GitHub Actions is not permitted to create pull requests in this repository, so this issue tracks the regeneration instead. | |
| **Click the link below to open a pre-filled PR:** | |
| 👉 [Create pull request from \`${BRANCH}\`](${COMPARE_URL}) | |
| Details: | |
| - Source: [${SOURCE_LABEL}](${TS_REF_URL}) | |
| - Branch: [\`${BRANCH}\`](${SERVER}/${REPO}/tree/${BRANCH}) | |
| - Latest workflow run: ${RUN_URL} | |
| ${CC_LINE}" | |
| fi | |
| # Write the final body onto the tracking issue (whether reused or | |
| # just created) and re-apply expected label and assignees. | |
| gh issue edit "$ISSUE_NUMBER" --body "$ISSUE_BODY" \ | |
| --add-label "typespec-python" \ | |
| --add-assignee "$ASSIGNEES" | |
| echo "issue_number=$ISSUE_NUMBER" >> $GITHUB_OUTPUT | |
| notify-on-failure: | |
| name: "Notify on failure" | |
| needs: regenerate | |
| if: failure() | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Send failure notification | |
| # SHA corresponds to actions/github-script@v7 | |
| uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b | |
| with: | |
| script: | | |
| const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`; | |
| const title = '[typespec-python] Regeneration workflow failed'; | |
| const body = `The TypeSpec Python test regeneration workflow failed.\n\n` + | |
| `- **Run:** ${runUrl}\n` + | |
| `- **Trigger:** ${context.eventName}\n\n` + | |
| `cc @iscai-msft @msyyc`; | |
| // Look for an existing open issue with the same title; if found, | |
| // add a comment instead of creating a duplicate. | |
| const existing = await github.rest.search.issuesAndPullRequests({ | |
| q: `repo:${context.repo.owner}/${context.repo.repo} is:issue is:open in:title "${title}"`, | |
| }); | |
| const match = existing.data.items.find( | |
| (i) => i.title === title && !i.pull_request, | |
| ); | |
| if (match) { | |
| core.info(`Commenting on existing issue #${match.number}`); | |
| await github.rest.issues.createComment({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| issue_number: match.number, | |
| body, | |
| }); | |
| } else { | |
| core.info('Creating new failure-notification issue'); | |
| await github.rest.issues.create({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| title, | |
| body, | |
| labels: ['typespec-python'], | |
| assignees: ['iscai-msft', 'msyyc'], | |
| }); | |
| } |