Skip to content

Add CLI E2E Recording Comment #691

Add CLI E2E Recording Comment

Add CLI E2E Recording Comment #691

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