Skip to content

Add CLI E2E Recording Comment #15625

Add CLI E2E Recording Comment

Add CLI E2E Recording Comment #15625

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