TypeSpec Python Regenerate Tests #10
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 | |
| issues: write | |
| pull-requests: 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" | |
| echo "typespec_ref=$REF" | |
| echo "typespec_display_ref=$DISPLAY_REF" | |
| echo "typespec_ref_url=$REF_URL" | |
| 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: Stage regenerated tests | |
| run: | | |
| set -euo pipefail | |
| GENERATED_STAGE_DIR="${RUNNER_TEMP}/typespec-python-generated" | |
| rm -rf "$GENERATED_STAGE_DIR" | |
| mkdir -p "$GENERATED_STAGE_DIR" | |
| cp -r "_typespec/packages/http-client-python/tests/generated/azure" "$GENERATED_STAGE_DIR/azure" | |
| cp -r "_typespec/packages/http-client-python/tests/generated/unbranded" "$GENERATED_STAGE_DIR/unbranded" | |
| echo "GENERATED_STAGE_DIR=$GENERATED_STAGE_DIR" >> "$GITHUB_ENV" | |
| - name: Checkout generated tests source branch | |
| run: | | |
| set -euo pipefail | |
| TARGET_BRANCH="typespec-python-generated-tests" | |
| PR_NUMBER="${{ steps.typespec-info.outputs.typespec_pr_number }}" | |
| if [ -n "$PR_NUMBER" ]; then | |
| SOURCE_BRANCH="regen/typespec-python-pr-${PR_NUMBER}" | |
| else | |
| SOURCE_BRANCH="regen/typespec-python-main" | |
| fi | |
| if ! git fetch --no-tags --depth=1 origin "$TARGET_BRANCH" 2>/dev/null; then | |
| echo "::error::Branch $TARGET_BRANCH not found. Initialize it from the generated-tests history so eng/tools/azure-sdk-tools/emitter/generated/template/README.md is present before rerunning this workflow." | |
| exit 1 | |
| fi | |
| git checkout -B "$SOURCE_BRANCH" "origin/$TARGET_BRANCH" | |
| - name: Copy regenerated tests | |
| run: | | |
| set -euo pipefail | |
| TARGET="eng/tools/azure-sdk-tools/emitter/generated" | |
| rm -rf "$TARGET/azure" "$TARGET/unbranded" | |
| mkdir -p "$TARGET" | |
| cp -r "$GENERATED_STAGE_DIR/azure" "$TARGET/azure" | |
| cp -r "$GENERATED_STAGE_DIR/unbranded" "$TARGET/unbranded" | |
| - name: Clean up typespec checkout | |
| run: | | |
| rm -rf "_typespec" | |
| if [ -n "${GENERATED_STAGE_DIR:-}" ]; then | |
| rm -rf "$GENERATED_STAGE_DIR" | |
| fi | |
| - name: Apply README template to generated test packages | |
| run: | | |
| set -euo pipefail | |
| TARGET="eng/tools/azure-sdk-tools/emitter/generated" | |
| 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 generated/ with the template, except: | |
| # - the top-level generated/README.md | |
| # - anything under generated/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: Create or update tracking issue with PR link | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| run: | | |
| set -euo pipefail | |
| TARGET_BRANCH="typespec-python-generated-tests" | |
| git config user.name "github-actions[bot]" | |
| git config user.email "github-actions[bot]@users.noreply.github.com" | |
| GENERATED_DIR="eng/tools/azure-sdk-tools/emitter/generated" | |
| # Quick check: skip if regeneration produced no changes vs HEAD. | |
| if [ -z "$(git status --porcelain -- "$GENERATED_DIR")" ]; then | |
| echo "No changes to commit" | |
| exit 0 | |
| fi | |
| PR_NUMBER="${{ steps.typespec-info.outputs.typespec_pr_number }}" | |
| # Use a distinct source branch per origin so a main-based run and a | |
| # PR-based run never force-push over each other. | |
| if [ -n "$PR_NUMBER" ]; then | |
| SOURCE_LABEL="microsoft/typespec PR #${PR_NUMBER}" | |
| SOURCE_BRANCH="regen/typespec-python-pr-${PR_NUMBER}" | |
| else | |
| SOURCE_LABEL="microsoft/typespec@main" | |
| SOURCE_BRANCH="regen/typespec-python-main" | |
| fi | |
| git add -f "$GENERATED_DIR"/ | |
| if git diff --cached --quiet; then | |
| echo "No changes vs $TARGET_BRANCH" | |
| exit 0 | |
| fi | |
| COMMIT_MSG="[typespec-python] Regenerate tests from ${SOURCE_LABEL}" | |
| git commit -m "$COMMIT_MSG" | |
| git push origin "$SOURCE_BRANCH" --force-with-lease | |
| # GitHub Actions' default GITHUB_TOKEN is not permitted to create pull | |
| # requests in this repository, so instead of opening a PR directly we | |
| # create/update a tracking issue containing a pre-filled "compare" link | |
| # that a maintainer can click to open the PR manually. | |
| REPO="${{ github.repository }}" | |
| SERVER="${{ github.server_url }}" | |
| RUN_URL="${SERVER}/${REPO}/actions/runs/${{ github.run_id }}" | |
| TS_REF_URL="${{ steps.typespec-info.outputs.typespec_ref_url }}" | |
| # Determine assignees. For manual (workflow_dispatch) triggers, assign | |
| # to the user who triggered the run. For automatic triggers (push, | |
| # schedule), 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="$COMMIT_MSG" | |
| # Reuse an existing open tracking issue if one exists (matched by exact | |
| # title). We list by label and match the title with jq because GitHub's | |
| # search tokenizer strips characters like [ ] @ # and /, making a title | |
| # search ambiguous. | |
| ISSUES_JSON=$(gh issue list --state open --label typespec-python \ | |
| --limit 100 --json number,title) | |
| EXISTING_ISSUE=$(jq -r --arg title "$TITLE" \ | |
| 'first(.[] | select(.title == $title) | .number) // ""' <<< "$ISSUES_JSON") | |
| 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. The body is filled in below once we have | |
| # the compare URL. | |
| # Assignees are applied best-effort in the final `gh issue edit` | |
| # below, so a non-assignable actor never fails issue creation. | |
| ISSUE_URL=$(gh issue create --title "$TITLE" \ | |
| --body "Tracking issue for TypeSpec Python regeneration. Details will be filled in shortly." \ | |
| --label "typespec-python") | |
| ISSUE_NUMBER="${ISSUE_URL##*/}" | |
| echo "Created issue #$ISSUE_NUMBER ($ISSUE_URL)" | |
| fi | |
| # If an open PR already exists from the source branch to the target | |
| # branch, point the tracking issue at it instead of asking for a new one. | |
| EXISTING_PR_JSON=$(gh pr list --state open --head "$SOURCE_BRANCH" --base "$TARGET_BRANCH" \ | |
| --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 \`${SOURCE_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: [\`${SOURCE_BRANCH}\`](${SERVER}/${REPO}/tree/${SOURCE_BRANCH}) | |
| - Latest workflow run: ${RUN_URL} | |
| > Note: the PR targets the \`${TARGET_BRANCH}\` branch, so merging it will **not** auto-close this issue. Please close this issue manually after the PR is merged. | |
| ${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. The PR is | |
| # based on the dedicated target branch, not main. | |
| ISSUE_LINK="${SERVER}/${REPO}/issues/${ISSUE_NUMBER}" | |
| # For PR-sourced runs, mark the prefilled PR as do-not-merge: it only | |
| # exists to surface the code diff of the upstream typespec PR. | |
| PR_TITLE="$TITLE" | |
| NOTE_PREFIX="" | |
| DRAFT_PARAM="" | |
| if [ -n "$PR_NUMBER" ]; then | |
| PR_TITLE="$TITLE (DO NOT MERGE)" | |
| NOTE_PREFIX=$'NOTE: This PR exists only to display the code diff. Please do not merge it.\n\n' | |
| # PR-sourced regenerations open the prefilled PR as a draft | |
| # (draft=1 is undocumented but honored by the quick_pull form). | |
| DRAFT_PARAM="&draft=1" | |
| fi | |
| PR_TITLE_ENC=$(jq -rn --arg t "$PR_TITLE" '$t|@uri') | |
| PR_BODY_RAW="${NOTE_PREFIX}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. Re-run the workflow to update it after new commits are pushed to the upstream TypeSpec PR" | |
| PR_BODY_ENC=$(jq -rn --arg b "$PR_BODY_RAW" '$b|@uri') | |
| COMPARE_URL="${SERVER}/${REPO}/compare/${TARGET_BRANCH}...${SOURCE_BRANCH}?quick_pull=1${DRAFT_PARAM}&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 \`${SOURCE_BRANCH}\`](${COMPARE_URL}) | |
| Details: | |
| - Source: [${SOURCE_LABEL}](${TS_REF_URL}) | |
| - Branch: [\`${SOURCE_BRANCH}\`](${SERVER}/${REPO}/tree/${SOURCE_BRANCH}) | |
| - Latest workflow run: ${RUN_URL} | |
| > Note: the PR targets the \`${TARGET_BRANCH}\` branch, so merging it will **not** auto-close this issue. Please close this issue manually after the PR is merged. | |
| ${CC_LINE}" | |
| fi | |
| # Write the final body onto the tracking issue (whether reused or just | |
| # created) and re-apply the expected label. Assignment is best-effort: | |
| # a non-assignable actor must not fail the workflow. | |
| gh issue edit "$ISSUE_NUMBER" --body "$ISSUE_BODY" --add-label "typespec-python" | |
| gh issue edit "$ISSUE_NUMBER" --add-assignee "$ASSIGNEES" \ | |
| || echo "::warning::Could not assign issue #${ISSUE_NUMBER} to ${ASSIGNEES}" | |
| echo "::notice::Tracking issue #${ISSUE_NUMBER} updated with PR compare link" | |
| - name: Clean up stale regen branches | |
| if: always() | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| run: | | |
| set -euo pipefail | |
| TARGET_BRANCH="typespec-python-generated-tests" | |
| PR_NUMBER="${{ steps.typespec-info.outputs.typespec_pr_number }}" | |
| if [ -n "$PR_NUMBER" ]; then | |
| CURRENT_BRANCH="regen/typespec-python-pr-${PR_NUMBER}" | |
| else | |
| CURRENT_BRANCH="regen/typespec-python-main" | |
| fi | |
| # Prune any regen/typespec-python-* branch whose PR (to the target | |
| # branch) is no longer open but did exist at some point (merged or | |
| # closed). Branches that never had a PR opened are left alone, since a | |
| # maintainer may still click the pre-filled compare link to open one. | |
| BRANCHES=$(git ls-remote --heads origin \ | |
| | sed 's#.*refs/heads/##' \ | |
| | grep '^regen/typespec-python-' || true) | |
| for b in $BRANCHES; do | |
| if [ "$b" = "$CURRENT_BRANCH" ]; then | |
| continue | |
| fi | |
| OPEN_COUNT=$(gh pr list --state open --head "$b" --base "$TARGET_BRANCH" \ | |
| --json number --jq 'length') | |
| ALL_COUNT=$(gh pr list --state all --head "$b" --base "$TARGET_BRANCH" \ | |
| --json number --jq 'length') | |
| if [ "$OPEN_COUNT" = "0" ] && [ "$ALL_COUNT" != "0" ]; then | |
| echo "Deleting stale branch $b (PR merged/closed, none open)" | |
| git push origin --delete "$b" \ | |
| || echo "::warning::Failed to delete branch $b" | |
| fi | |
| done | |
| 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'], | |
| }); | |
| } |