Add CLI E2E Recording Comment #15624
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 | |
| pr_number: | |
| description: 'Optional PR number to comment on (skips the head-SHA lookup; useful for testing against merged PRs)' | |
| required: false | |
| 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 == 'microsoft' && | |
| (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; | |
| // Allow explicit PR override so we can dry-run against a merged PR's | |
| // artifacts without depending on the open-PR head-SHA lookup below. | |
| const overridePr = context.payload.inputs.pr_number; | |
| if (overridePr) { | |
| prNumber = Number(overridePr); | |
| } else { | |
| // 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 recording artifacts (simple pattern match) | |
| // These are uploaded by the run-tests workflow with name: cli-e2e-recordings-{TestName} | |
| const cliE2eArtifacts = allArtifacts.filter(a => | |
| a.name.startsWith('cli-e2e-recordings-') | |
| ); | |
| console.log(`Found ${cliE2eArtifacts.length} CLI E2E recording artifacts`); | |
| // Build metadata mapping testShortName to artifact IDs for recordings and logs. | |
| // Recording artifacts: cli-e2e-recordings-{testShortName} | |
| // Logs artifacts: logs-{testShortName}-{os} | |
| const artifactMeta = {}; | |
| for (const artifact of cliE2eArtifacts) { | |
| const testShortName = artifact.name.replace('cli-e2e-recordings-', ''); | |
| artifactMeta[testShortName] = { | |
| recordingArtifactId: artifact.id, | |
| logsArtifactId: null | |
| }; | |
| } | |
| // Find matching logs artifacts for each testShortName | |
| const logsArtifacts = allArtifacts.filter(a => a.name.startsWith('logs-')); | |
| for (const [testShortName, meta] of Object.entries(artifactMeta)) { | |
| const match = logsArtifacts.find(a => a.name.startsWith(`logs-${testShortName}-`)); | |
| if (match) { | |
| meta.logsArtifactId = match.id; | |
| } | |
| } | |
| fs.writeFileSync('artifact_meta.json', JSON.stringify(artifactMeta, null, 2)); | |
| console.log('Artifact metadata:', JSON.stringify(artifactMeta, null, 2)); | |
| // 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 | |
| mkdir -p trx_files | |
| # Track which .cast file came from which testShortName so we can link | |
| # to the correct job and artifacts in the PR comment. | |
| declare -A CAST_TO_SHORT_NAME | |
| for zipfile in recordings/*.zip; do | |
| if [ -f "$zipfile" ]; then | |
| echo "Extracting $zipfile..." | |
| # Artifact zip name: cli-e2e-recordings-{testShortName}.zip | |
| ARTIFACT_NAME=$(basename "$zipfile" .zip) | |
| TEST_SHORT_NAME="${ARTIFACT_NAME#cli-e2e-recordings-}" | |
| EXTRACT_DIR="recordings/extracted_${ARTIFACT_NAME}" | |
| unzip -o "$zipfile" -d "$EXTRACT_DIR" || true | |
| # Copy .cast files and record their source testShortName | |
| find "$EXTRACT_DIR" -name "*.cast" | while read -r castfile; do | |
| CAST_BASENAME=$(basename "$castfile") | |
| cp "$castfile" "cast_files/$CAST_BASENAME" | |
| echo "${CAST_BASENAME%.cast}=${TEST_SHORT_NAME}" >> cast_source_map.txt | |
| done | |
| # Copy .trx files | |
| find "$EXTRACT_DIR" -name "*.trx" -exec cp {} trx_files/ \; 2>/dev/null || true | |
| fi | |
| done | |
| echo "Found recordings:" | |
| ls -la cast_files/ || echo "No .cast files found" | |
| echo "Found TRX files:" | |
| ls -la trx_files/ || echo "No .trx files found" | |
| - name: Parse test results from TRX files | |
| if: steps.run-info.outputs.skip != 'true' && hashFiles('cast_files/*.cast') != '' | |
| id: test-results | |
| continue-on-error: true | |
| shell: bash | |
| run: | | |
| # Parse TRX (XML) files to extract test method outcomes using yq + jq | |
| # (both pre-installed on ubuntu-latest). Produces a JSON map of | |
| # testMethodName -> outcome for the bash comment step to consume. | |
| # When the same test appears in multiple TRX files (e.g. retries), | |
| # "Failed" wins over other outcomes. | |
| if compgen -G trx_files/*.trx > /dev/null 2>&1; then | |
| echo "Parsing TRX files with yq..." | |
| # Convert each TRX to JSON with yq (Go yq's expression language is | |
| # limited, so do all reshaping in jq). The resulting documents look like: | |
| # { "TestRun": { "Results": { "UnitTestResult": <object|array> } } } | |
| # Attribute keys are exposed by yq as "+@<attr>" (newer yq) or | |
| # "@<attr>" (older yq), and a single result is emitted as an object | |
| # rather than an array, so jq must handle both shapes. | |
| yq -p xml -o json '.' trx_files/*.trx \ | |
| | jq -s ' | |
| def attr(o; k): o["+@" + k] // o["@" + k]; | |
| # Best-effort: also key by the bare method name (with theory | |
| # parameter data stripped) so a .cast file named after the | |
| # CallerMemberName matches a TRX entry like | |
| # "Namespace.Class.Method(toolchain: "pnpm")". | |
| def bare_method(name): | |
| (name | split(".") | last) | sub("\\(.*$"; ""); | |
| def fqn_no_params(name): | |
| name | sub("\\(.*$"; ""); | |
| def merge(map; key; outcome): | |
| if map[key] == "Failed" then map else map + {(key): outcome} end; | |
| reduce ( | |
| .[] | |
| | .TestRun.Results.UnitTestResult | |
| | (if type == "array" then .[] else . end) | |
| ) as $r ({}; | |
| attr($r; "testName") as $name | | |
| attr($r; "outcome") as $outcome | | |
| if $name == null or $outcome == null then . | |
| else | |
| merge(.; bare_method($name); $outcome) | |
| | merge(.; fqn_no_params($name); $outcome) | |
| | merge(.; $name; $outcome) | |
| end | |
| ) | |
| ' > test_outcomes.json | |
| OUTCOME_COUNT=$(jq 'length' test_outcomes.json) | |
| echo "Parsed $OUTCOME_COUNT test outcome(s)" | |
| if [ "$OUTCOME_COUNT" -gt 0 ]; then | |
| echo "has_outcomes=true" >> "$GITHUB_OUTPUT" | |
| else | |
| echo "has_outcomes=false" >> "$GITHUB_OUTPUT" | |
| fi | |
| else | |
| echo "No TRX files found" | |
| echo '{}' > test_outcomes.json | |
| echo "has_outcomes=false" >> "$GITHUB_OUTPUT" | |
| fi | |
| - name: Look up job information for CI run | |
| if: steps.run-info.outputs.skip != 'true' && hashFiles('cast_files/*.cast') != '' | |
| id: job-info | |
| continue-on-error: true | |
| uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 | |
| with: | |
| retries: 3 | |
| script: | | |
| const fs = require('fs'); | |
| const runId = ${{ steps.run-info.outputs.run_id }}; | |
| const jobMap = {}; | |
| try { | |
| // Read short names first so we can stop pagination early once all are found | |
| const castSourceMap = {}; | |
| if (fs.existsSync('cast_source_map.txt')) { | |
| const lines = fs.readFileSync('cast_source_map.txt', 'utf8').trim().split('\n'); | |
| for (const line of lines) { | |
| const [castName, shortName] = line.split('='); | |
| if (castName && shortName) { | |
| castSourceMap[castName] = shortName; | |
| } | |
| } | |
| } | |
| const shortNames = [...new Set(Object.values(castSourceMap))]; | |
| console.log(`Looking up jobs for ${shortNames.length} short name(s): ${JSON.stringify(shortNames)}`); | |
| // Use per_page=50 because the GitHub API returns 502 "Server Error" | |
| // with per_page=100 when runs have many jobs (300+). The response | |
| // payload exceeds an internal size limit at larger page sizes. | |
| const allJobs = await github.paginate( | |
| github.rest.actions.listJobsForWorkflowRun, | |
| { | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| run_id: runId, | |
| per_page: 50 | |
| } | |
| ); | |
| console.log(`Fetched ${allJobs.length} jobs total`); | |
| for (const job of allJobs) { | |
| if (!job.name) continue; | |
| for (const shortName of shortNames) { | |
| if (job.name.includes(shortName)) { | |
| jobMap[shortName] = { url: job.html_url, id: job.id }; | |
| } | |
| } | |
| } | |
| console.log(`Matched ${Object.keys(jobMap).length}/${shortNames.length} short names to jobs`); | |
| const unmatched = shortNames.filter(n => !jobMap[n]); | |
| if (unmatched.length > 0) { | |
| console.log(`Unmatched: ${JSON.stringify(unmatched)}`); | |
| } | |
| } catch (error) { | |
| core.error(`Failed to look up job information: ${error.message}`); | |
| console.log(error.stack); | |
| } finally { | |
| fs.writeFileSync('job_map.json', JSON.stringify(jobMap, null, 2)); | |
| console.log('Job map:', JSON.stringify(jobMap, null, 2)); | |
| } | |
| - 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 | |
| # Load test outcomes from TRX parsing step (JSON: {"methodName": "Passed|Failed", ...}) | |
| HAS_OUTCOMES="${{ steps.test-results.outputs.has_outcomes }}" | |
| if [ "$HAS_OUTCOMES" = "true" ] && [ -f "test_outcomes.json" ]; then | |
| echo "Loaded test outcomes from TRX files" | |
| else | |
| echo "No test outcomes available, will show recordings without pass/fail status" | |
| echo '{}' > test_outcomes.json | |
| fi | |
| # Load cast-to-testShortName mapping, job URLs, and artifact metadata | |
| # so we can link each recording to its CI job and downloadable artifacts. | |
| declare -A CAST_SOURCE_MAP | |
| if [ -f "cast_source_map.txt" ]; then | |
| while IFS='=' read -r cast_name short_name; do | |
| CAST_SOURCE_MAP["$cast_name"]="$short_name" | |
| done < cast_source_map.txt | |
| fi | |
| # Ensure job_map.json and artifact_meta.json exist (may be absent if steps were skipped) | |
| [ -f "job_map.json" ] || echo '{}' > job_map.json | |
| [ -f "artifact_meta.json" ] || echo '{}' > artifact_meta.json | |
| # Unique marker to identify CLI E2E recording comments | |
| COMMENT_MARKER="<!-- cli-e2e-recordings -->" | |
| # Retry configuration for asciinema uploads | |
| MAX_UPLOAD_RETRIES=5 | |
| RETRY_BASE_DELAY_SECONDS=30 | |
| UPLOAD_COUNT=0 | |
| FAIL_COUNT=0 | |
| TOTAL_COUNT=0 | |
| TEST_PASS_COUNT=0 | |
| TEST_FAIL_COUNT=0 | |
| TEST_UNKNOWN_COUNT=0 | |
| # Arrays to track failed test recordings separately | |
| FAILED_TESTS_BODY="" | |
| TABLE_BODY="" | |
| for castfile in "$RECORDINGS_DIR"/*.cast; do | |
| if [ -f "$castfile" ]; then | |
| filename=$(basename "$castfile" .cast) | |
| echo "Uploading $castfile..." | |
| TOTAL_COUNT=$((TOTAL_COUNT + 1)) | |
| # Sanitize filename for safe markdown rendering. | |
| # .cast files are named via [CallerMemberName] so should be valid C# identifiers, | |
| # but we sanitize defensively since this runs in a privileged workflow_run context | |
| # and artifacts could come from fork PRs. | |
| safe_filename=$(echo "$filename" | tr -cd 'A-Za-z0-9_.-') | |
| # Look up test outcome from TRX data. | |
| # .cast files are named after the test method name (via [CallerMemberName] in CreateTestTerminal), | |
| # so the filename matches the method name key in the outcomes JSON. | |
| TEST_OUTCOME=$(jq -r --arg name "$filename" '.[$name] // "Unknown"' test_outcomes.json) | |
| if [ "$TEST_OUTCOME" = "Passed" ]; then | |
| STATUS_EMOJI="✅" | |
| TEST_PASS_COUNT=$((TEST_PASS_COUNT + 1)) | |
| elif [ "$TEST_OUTCOME" = "Failed" ]; then | |
| STATUS_EMOJI="❌" | |
| TEST_FAIL_COUNT=$((TEST_FAIL_COUNT + 1)) | |
| else | |
| STATUS_EMOJI="❔" | |
| TEST_UNKNOWN_COUNT=$((TEST_UNKNOWN_COUNT + 1)) | |
| fi | |
| # Upload to asciinema with retry logic for transient failures | |
| ASCIINEMA_URL="" | |
| for attempt in $(seq 1 "$MAX_UPLOAD_RETRIES"); do | |
| 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 | |
| break | |
| fi | |
| if [ "$attempt" -lt "$MAX_UPLOAD_RETRIES" ]; then | |
| DELAY=$((attempt * RETRY_BASE_DELAY_SECONDS)) | |
| echo "Upload attempt $attempt failed, retrying in ${DELAY}s..." | |
| sleep "$DELAY" | |
| fi | |
| done | |
| # Resolve job URL and artifacts URL for this recording's test run. | |
| # The cast_source_map maps .cast filename -> testShortName, which keys | |
| # into job_map.json (testShortName -> job HTML URL) and | |
| # artifact_meta.json (testShortName -> {recordingArtifactId, logsArtifactId}). | |
| TEST_SHORT_NAME="${CAST_SOURCE_MAP[$filename]:-}" | |
| JOB_URL="" | |
| JOB_ID="" | |
| ARTIFACTS_URL="" | |
| if [ -n "$TEST_SHORT_NAME" ]; then | |
| JOB_URL=$(jq -r --arg name "$TEST_SHORT_NAME" '.[$name].url // ""' job_map.json) | |
| JOB_ID=$(jq -r --arg name "$TEST_SHORT_NAME" '.[$name].id // ""' job_map.json) | |
| LOGS_ARTIFACT_ID=$(jq -r --arg name "$TEST_SHORT_NAME" '.[$name].logsArtifactId // ""' artifact_meta.json) | |
| if [ -n "$LOGS_ARTIFACT_ID" ] && [ "$LOGS_ARTIFACT_ID" != "null" ]; then | |
| ARTIFACTS_URL="https://github.com/${GITHUB_REPOSITORY}/actions/runs/${RUN_ID}/artifacts/${LOGS_ARTIFACT_ID}" | |
| fi | |
| fi | |
| # Build the detail cell with links separated by · | |
| DETAIL_PARTS="" | |
| if [ -n "$ASCIINEMA_URL" ]; then | |
| DETAIL_PARTS="[Recording](${ASCIINEMA_URL})" | |
| echo "Uploaded: $ASCIINEMA_URL" | |
| UPLOAD_COUNT=$((UPLOAD_COUNT + 1)) | |
| else | |
| DETAIL_PARTS="⚠️ Upload failed" | |
| echo "Failed to upload $castfile after $MAX_UPLOAD_RETRIES attempts" | |
| FAIL_COUNT=$((FAIL_COUNT + 1)) | |
| fi | |
| if [ -n "$JOB_URL" ]; then | |
| DETAIL_PARTS="${DETAIL_PARTS} · [Job](${JOB_URL})" | |
| fi | |
| if [ -n "$ARTIFACTS_URL" ]; then | |
| DETAIL_PARTS="${DETAIL_PARTS} · [CLI logs](${ARTIFACTS_URL})" | |
| fi | |
| # Build the table row once; append to both tables as needed. | |
| ROW="| ${STATUS_EMOJI} | ${safe_filename} | ${DETAIL_PARTS} |" | |
| TABLE_BODY="${TABLE_BODY} | |
| ${ROW}" | |
| if [ "$TEST_OUTCOME" = "Failed" ]; then | |
| FAILED_TESTS_BODY="${FAILED_TESTS_BODY} | |
| ${ROW}" | |
| fi | |
| fi | |
| done | |
| echo "Uploaded $UPLOAD_COUNT recordings, $FAIL_COUNT upload failures, $TEST_PASS_COUNT passed, $TEST_FAIL_COUNT failed, $TEST_UNKNOWN_COUNT unknown" | |
| # Build the summary line in the same style as the deployment E2E comment: | |
| # "<emoji> **CLI E2E Tests <status>** — X passed, Y failed[, Z unknown]" | |
| # Status reflects test outcomes; recording-upload failures are a secondary concern | |
| # surfaced in the table rather than the headline status. | |
| # Choose headline emoji + status word from the tallied outcomes. | |
| # We never let unknowns suppress a real failure, but we do flag | |
| # unknowns explicitly when the rest of the run was clean so | |
| # reviewers don't read a misleading 'passed'. | |
| FALLBACK_TEXT="" | |
| if [ "$TEST_FAIL_COUNT" -gt 0 ]; then | |
| SUMMARY_EMOJI="❌" | |
| SUMMARY_STATUS="failed" | |
| elif [ "$TEST_PASS_COUNT" -gt 0 ] && [ "$TEST_UNKNOWN_COUNT" -eq 0 ]; then | |
| SUMMARY_EMOJI="✅" | |
| SUMMARY_STATUS="passed" | |
| elif [ "$TEST_PASS_COUNT" -eq 0 ] && [ "$TEST_FAIL_COUNT" -eq 0 ]; then | |
| # No TRX outcomes matched any recording — describe the run by | |
| # recording count rather than zero pass/fail counts, which would | |
| # read as 'no tests ran' instead of 'outcome data unavailable'. | |
| SUMMARY_EMOJI="🎬" | |
| SUMMARY_STATUS="completed" | |
| FALLBACK_TEXT="${TOTAL_COUNT} recording(s), outcomes unavailable" | |
| else | |
| SUMMARY_EMOJI="❓" | |
| SUMMARY_STATUS="unknown" | |
| fi | |
| if [ -n "$FALLBACK_TEXT" ]; then | |
| SUMMARY_TEXT="$FALLBACK_TEXT" | |
| else | |
| SUMMARY_TEXT="${TEST_PASS_COUNT} passed, ${TEST_FAIL_COUNT} failed" | |
| if [ "$TEST_UNKNOWN_COUNT" -gt 0 ]; then | |
| SUMMARY_TEXT="${SUMMARY_TEXT}, ${TEST_UNKNOWN_COUNT} unknown" | |
| fi | |
| fi | |
| if [ "$FAIL_COUNT" -gt 0 ]; then | |
| SUMMARY_TEXT="${SUMMARY_TEXT} (${UPLOAD_COUNT}/${TOTAL_COUNT} recordings uploaded, ${FAIL_COUNT} upload(s) failed)" | |
| fi | |
| # Build the failed tests section (shown outside the collapsible) | |
| FAILED_SECTION="" | |
| if [ -n "$FAILED_TESTS_BODY" ]; then | |
| FAILED_SECTION=" | |
| ### ❌ Failed Tests | |
| | - | Test | Detail | | |
| |--------|------|-----------|${FAILED_TESTS_BODY} | |
| " | |
| fi | |
| COMMENT_BODY="${COMMENT_MARKER} | |
| ${SUMMARY_EMOJI} **CLI E2E Tests ${SUMMARY_STATUS}** — ${SUMMARY_TEXT} (commit \`${SHORT_SHA}\`) | |
| ${FAILED_SECTION} | |
| <details> | |
| <summary>View all recordings</summary> | |
| | - | Test | Detail | | |
| |--------|------|-----------|${TABLE_BODY} | |
| --- | |
| <sub>📹 Recordings uploaded automatically from [CI run #${RUN_ID}](https://github.com/${GITHUB_REPOSITORY}/actions/runs/${RUN_ID})</sub> | |
| </details>" | |
| # Delete any existing recording comments, then post the new one | |
| EXISTING_COMMENT_IDS=$(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') || true | |
| for COMMENT_ID in $EXISTING_COMMENT_IDS; do | |
| echo "Deleting old comment $COMMENT_ID" | |
| gh api \ | |
| --method DELETE \ | |
| -H "Accept: application/vnd.github+json" \ | |
| "/repos/${GITHUB_REPOSITORY}/issues/comments/${COMMENT_ID}" || true | |
| done | |
| echo "Creating new comment on PR #${PR_NUMBER}" | |
| gh pr comment "${PR_NUMBER}" --repo "$GITHUB_REPOSITORY" --body "$COMMENT_BODY" | |
| echo "Posted comment to PR #${PR_NUMBER}" | |
| else | |
| echo "No recordings found in $RECORDINGS_DIR" | |
| fi |