Add CLI E2E Recording Comment #691
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: Add CLI E2E Recording Comment | |
| on: | |
| # Trigger when the CI workflow completes (success, failure, or cancelled) | |
| # We want to post recordings even if CI was cancelled since recordings may still exist | |
| workflow_run: | |
| workflows: ["CI"] | |
| types: | |
| - completed | |
| # Allow manual triggering for testing | |
| workflow_dispatch: | |
| inputs: | |
| run_id: | |
| description: 'Workflow run ID to download artifacts from' | |
| required: true | |
| type: number | |
| jobs: | |
| add-recording-comment: | |
| # Only run on the dotnet org and for pull requests | |
| # Note: This runs for all conclusions (success, failure, cancelled) since recordings may exist | |
| if: >- | |
| ${{ github.repository_owner == 'dotnet' && | |
| (github.event.workflow_run.event == 'pull_request' || github.event_name == 'workflow_dispatch') }} | |
| runs-on: ubuntu-latest | |
| permissions: | |
| pull-requests: write | |
| actions: read | |
| steps: | |
| - name: Log workflow run info | |
| if: ${{ github.event_name == 'workflow_run' }} | |
| run: | | |
| echo "CI workflow conclusion: ${{ github.event.workflow_run.conclusion }}" | |
| echo "CI workflow run ID: ${{ github.event.workflow_run.id }}" | |
| - name: Get workflow run info | |
| id: run-info | |
| uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 | |
| with: | |
| script: | | |
| let runId, prNumber, headSha; | |
| if (context.eventName === 'workflow_dispatch') { | |
| // Manual trigger - get run info from input | |
| runId = context.payload.inputs.run_id; | |
| const run = await github.rest.actions.getWorkflowRun({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| run_id: runId | |
| }); | |
| headSha = run.data.head_sha; | |
| // Find PR by head SHA | |
| const prs = await github.rest.pulls.list({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| state: 'open', | |
| head: `${context.repo.owner}:${run.data.head_branch}` | |
| }); | |
| prNumber = prs.data.length > 0 ? prs.data[0].number : null; | |
| } else { | |
| // Triggered by workflow_run | |
| runId = context.payload.workflow_run.id; | |
| headSha = context.payload.workflow_run.head_sha; | |
| // Get PR number from the workflow run | |
| const prs = context.payload.workflow_run.pull_requests; | |
| prNumber = prs && prs.length > 0 ? prs[0].number : null; | |
| } | |
| if (!prNumber) { | |
| console.log('No PR found for this workflow run, skipping comment'); | |
| core.setOutput('skip', 'true'); | |
| return; | |
| } | |
| core.setOutput('skip', 'false'); | |
| core.setOutput('run_id', runId); | |
| core.setOutput('pr_number', prNumber); | |
| core.setOutput('head_sha', headSha); | |
| console.log(`Run ID: ${runId}, PR: ${prNumber}, SHA: ${headSha}`); | |
| - name: Download CLI E2E test artifacts | |
| if: steps.run-info.outputs.skip != 'true' | |
| uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 | |
| with: | |
| script: | | |
| const fs = require('fs'); | |
| const path = require('path'); | |
| const runId = ${{ steps.run-info.outputs.run_id }}; | |
| // List ALL artifacts for the workflow run using pagination | |
| // (without pagination we only get the first page and miss CLI E2E artifacts) | |
| const allArtifacts = await github.paginate( | |
| github.rest.actions.listWorkflowRunArtifacts, | |
| { | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| run_id: runId, | |
| per_page: 100 | |
| } | |
| ); | |
| console.log(`Total artifacts found: ${allArtifacts.length}`); | |
| // Filter for CLI E2E test logs (they contain recordings) | |
| const cliE2eArtifacts = allArtifacts.filter(a => | |
| a.name.startsWith('logs-') && | |
| a.name.includes('Tests-ubuntu-latest') && | |
| (a.name.includes('SmokeTests') || | |
| a.name.includes('EmptyAppHostTemplateTests') || | |
| a.name.includes('JsReactTemplateTests') || | |
| a.name.includes('PythonReactTemplateTests') || | |
| a.name.includes('DockerDeploymentTests') || | |
| a.name.includes('TypeScriptPolyglotTests') || | |
| a.name.includes('DoctorCommandTests') || | |
| a.name.includes('StartStopTests') || | |
| a.name.includes('PsCommandTests')) | |
| ); | |
| console.log(`Found ${cliE2eArtifacts.length} CLI E2E artifacts`); | |
| // Create recordings directory | |
| const recordingsDir = 'recordings'; | |
| fs.mkdirSync(recordingsDir, { recursive: true }); | |
| // Download each artifact | |
| for (const artifact of cliE2eArtifacts) { | |
| console.log(`Downloading ${artifact.name}...`); | |
| const download = await github.rest.actions.downloadArtifact({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| artifact_id: artifact.id, | |
| archive_format: 'zip' | |
| }); | |
| const artifactPath = path.join(recordingsDir, `${artifact.name}.zip`); | |
| fs.writeFileSync(artifactPath, Buffer.from(download.data)); | |
| console.log(`Saved to ${artifactPath}`); | |
| } | |
| core.setOutput('artifact_count', cliE2eArtifacts.length); | |
| - name: Extract recordings from artifacts | |
| if: steps.run-info.outputs.skip != 'true' | |
| shell: bash | |
| run: | | |
| mkdir -p cast_files | |
| for zipfile in recordings/*.zip; do | |
| if [ -f "$zipfile" ]; then | |
| echo "Extracting $zipfile..." | |
| unzip -o "$zipfile" -d "recordings/extracted_$(basename "$zipfile" .zip)" || true | |
| fi | |
| done | |
| # Find and copy all .cast files | |
| find recordings -name "*.cast" -exec cp {} cast_files/ \; 2>/dev/null || true | |
| echo "Found recordings:" | |
| ls -la cast_files/ || echo "No .cast files found" | |
| - name: Upload recordings and post comment | |
| if: steps.run-info.outputs.skip != 'true' | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| GITHUB_REPOSITORY: ${{ github.repository }} | |
| GITHUB_REPOSITORY_OWNER: ${{ github.repository_owner }} | |
| GITHUB_EVENT_REPO_NAME: ${{ github.event.repository.name }} | |
| shell: bash | |
| run: | | |
| PR_NUMBER="${{ steps.run-info.outputs.pr_number }}" | |
| RUN_ID="${{ steps.run-info.outputs.run_id }}" | |
| HEAD_SHA="${{ steps.run-info.outputs.head_sha }}" | |
| SHORT_SHA="${HEAD_SHA:0:7}" | |
| RECORDINGS_DIR="cast_files" | |
| if [ -d "$RECORDINGS_DIR" ] && compgen -G "$RECORDINGS_DIR"/*.cast > /dev/null; then | |
| # Install asciinema | |
| pip install asciinema | |
| # Unique marker to identify CLI E2E recording comments | |
| COMMENT_MARKER="<!-- cli-e2e-recordings -->" | |
| # Build comment body | |
| COMMENT_BODY="${COMMENT_MARKER} | |
| ## 🎬 CLI E2E Test Recordings | |
| The following terminal recordings are available for commit \`${SHORT_SHA}\`: | |
| | Test | Recording | | |
| |------|-----------|" | |
| UPLOAD_COUNT=0 | |
| FAIL_COUNT=0 | |
| for castfile in "$RECORDINGS_DIR"/*.cast; do | |
| if [ -f "$castfile" ]; then | |
| filename=$(basename "$castfile" .cast) | |
| echo "Uploading $castfile..." | |
| # Upload to asciinema and capture URL | |
| UPLOAD_OUTPUT=$(asciinema upload "$castfile" 2>&1) || true | |
| ASCIINEMA_URL=$(echo "$UPLOAD_OUTPUT" | grep -oP 'https://asciinema\.org/a/[a-zA-Z0-9_-]+' | head -1) || true | |
| if [ -n "$ASCIINEMA_URL" ]; then | |
| COMMENT_BODY="${COMMENT_BODY} | |
| | ${filename} | [▶️ View Recording](${ASCIINEMA_URL}) |" | |
| echo "Uploaded: $ASCIINEMA_URL" | |
| UPLOAD_COUNT=$((UPLOAD_COUNT + 1)) | |
| else | |
| COMMENT_BODY="${COMMENT_BODY} | |
| | ${filename} | ❌ Upload failed |" | |
| echo "Failed to upload $castfile" | |
| FAIL_COUNT=$((FAIL_COUNT + 1)) | |
| fi | |
| fi | |
| done | |
| COMMENT_BODY="${COMMENT_BODY} | |
| --- | |
| <sub>📹 Recordings uploaded automatically from [CI run #${RUN_ID}](https://github.com/${GITHUB_REPOSITORY}/actions/runs/${RUN_ID})</sub>" | |
| echo "Uploaded $UPLOAD_COUNT recordings, $FAIL_COUNT failures" | |
| # Check for existing recording comment using GraphQL (more efficient than fetching all comments) | |
| # Filter by github-actions author to only match our own comments | |
| EXISTING_COMMENT_ID=$(gh api graphql -f query=' | |
| query($owner: String!, $repo: String!, $pr: Int!) { | |
| repository(owner: $owner, name: $repo) { | |
| pullRequest(number: $pr) { | |
| comments(first: 100) { | |
| nodes { | |
| databaseId | |
| author { login } | |
| body | |
| } | |
| } | |
| } | |
| } | |
| }' -f owner="$GITHUB_REPOSITORY_OWNER" -f repo="$GITHUB_EVENT_REPO_NAME" -F pr="$PR_NUMBER" \ | |
| --jq '.data.repository.pullRequest.comments.nodes[] | select(.author.login == "github-actions" and (.body | contains("'"${COMMENT_MARKER}"'"))) | .databaseId' \ | |
| | head -1) || true | |
| if [ -n "$EXISTING_COMMENT_ID" ]; then | |
| echo "Updating existing comment $EXISTING_COMMENT_ID" | |
| gh api \ | |
| --method PATCH \ | |
| -H "Accept: application/vnd.github+json" \ | |
| "/repos/${GITHUB_REPOSITORY}/issues/comments/${EXISTING_COMMENT_ID}" \ | |
| -f body="$COMMENT_BODY" | |
| else | |
| echo "Creating new comment on PR #${PR_NUMBER}" | |
| gh pr comment "${PR_NUMBER}" --repo "$GITHUB_REPOSITORY" --body "$COMMENT_BODY" | |
| fi | |
| echo "Posted comment to PR #${PR_NUMBER}" | |
| else | |
| echo "No recordings found in $RECORDINGS_DIR" | |
| fi |