Skip to content

Add CLI E2E Recording Comment #12610

Add CLI E2E Recording Comment

Add CLI E2E Recording Comment #12610

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`);
// 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
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
# Find and copy all .trx files (test results)
find recordings -name "*.trx" -exec cp {} trx_files/ \; 2>/dev/null || true
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: 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
# 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.
# Per-link label carries the outcome too, so a recording URL copied out of
# the table still tells reviewers whether it represents a pass or a failure.
TEST_OUTCOME=$(jq -r --arg name "$filename" '.[$name] // "Unknown"' test_outcomes.json)
if [ "$TEST_OUTCOME" = "Passed" ]; then
STATUS_EMOJI="✅"
LINK_LABEL="✅ ▶️ View recording"
TEST_PASS_COUNT=$((TEST_PASS_COUNT + 1))
elif [ "$TEST_OUTCOME" = "Failed" ]; then
STATUS_EMOJI="❌"
LINK_LABEL="❌ ▶️ View failure recording"
TEST_FAIL_COUNT=$((TEST_FAIL_COUNT + 1))
else
STATUS_EMOJI="❔"
LINK_LABEL="❔ ▶️ View recording"
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
if [ -n "$ASCIINEMA_URL" ]; then
TABLE_BODY="${TABLE_BODY}
| ${STATUS_EMOJI} | ${safe_filename} | [${LINK_LABEL}](${ASCIINEMA_URL}) |"
echo "Uploaded: $ASCIINEMA_URL"
UPLOAD_COUNT=$((UPLOAD_COUNT + 1))
# Track failed tests for the prominent section
if [ "$TEST_OUTCOME" = "Failed" ]; then
FAILED_TESTS_BODY="${FAILED_TESTS_BODY}
- ❌ **${safe_filename}** — [${LINK_LABEL}](${ASCIINEMA_URL})"
fi
else
TABLE_BODY="${TABLE_BODY}
| ${STATUS_EMOJI} | ${safe_filename} | ⚠️ Upload failed |"
echo "Failed to upload $castfile after $MAX_UPLOAD_RETRIES attempts"
FAIL_COUNT=$((FAIL_COUNT + 1))
if [ "$TEST_OUTCOME" = "Failed" ]; then
FAILED_TESTS_BODY="${FAILED_TESTS_BODY}
- ❌ **${safe_filename}** — ⚠️ Recording upload failed"
fi
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
${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>
| Status | Test | Recording |
|--------|------|-----------|${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