From 29aa3e3e9fc7b625830f8bae96b6ecbf95915057 Mon Sep 17 00:00:00 2001 From: Petr Heinz Date: Thu, 22 Oct 2026 09:32:42 +0200 Subject: [PATCH 1/2] T-22072 [CI] Name the test in the E2E Linear issue and include the failure summary The issue title names the test (E2E failure: AWS Lambda via CloudWatch) and its body carries the failed step's errors and the job summary. The read-back step writes a summary however it fails: what arrived against what was expected, and which stored rows differ. A failing Lambda error check adds the errors. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/e2e.yml | 68 +++++++++++++++++-- .github/workflows/notify_linear_failure.yml | 72 ++++++++++++++++----- 2 files changed, 119 insertions(+), 21 deletions(-) diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 74b5c16..569aeb8 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -177,6 +177,30 @@ jobs: - name: "[Test] Read the lines back through the SQL API connection" run: | set -o pipefail + mkdir -p out + fail() { FAILURE="$1"; echo "::error::$1"; [ -z "${2:-}" ] || echo "$2"; exit 1; } + # Every way this step fails still leaves a summary: what arrived against what was expected. + summarize_failure() { + local status=$? + [ "$status" = 0 ] && return + { + echo "### AWS CloudWatch Lambda E2E \`$RUN_ID\`" + echo + echo "❌ ${FAILURE:-The read-back stopped on an error, see the log of this step}" + if [ -n "${TOTAL:-}" ]; then + echo + echo "| read back through \`$CONNECT_HOST\` | arrived | expected |" + echo "|---|---|---|" + echo "| rows | $TOTAL | 20 |" + echo "| level error | $ERRORS | 5 |" + echo "| level info | $INFOS | 15 |" + echo "| lambda name \`$FUNCTION_NAME\` | $NAMED | 20 |" + fi + [ -z "${PROBLEMS:-}" ] || { echo; echo '```'; head -20 <<<"$PROBLEMS"; echo '```'; } + } > out/summary.md + exit "$status" + } + trap summarize_failure EXIT sql() { CODE=$(curl -sS -o response.json -w '%{http_code}' -u "$TEST_TEAM_CONNECTION_USERNAME:$TEST_TEAM_CONNECTION_PASSWORD" -H 'Content-Type: plain/text' --data-binary "$1 FORMAT JSONEachRow" "https://$CONNECT_HOST/") # A source's collection exists only from its first stored row on; until then the query is "no rows yet". @@ -196,14 +220,13 @@ jobs: while :; do counts [ "$TOTAL" -ge 20 ] && break - [ "$SECONDS" -lt "$DEADLINE" ] || { echo "::error::only $TOTAL of 20 lines arrived within 5 min"; exit 1; } + [ "$SECONDS" -lt "$DEADLINE" ] || fail "Only $TOTAL of 20 lines arrived within 5 min" sleep 15 done # Everything arrived; a last look 30 s later catches duplicate delivery. sleep 30 counts - [ "$TOTAL" = 20 ] && [ "$ERRORS" = 5 ] && [ "$INFOS" = 15 ] && [ "$NAMED" = 20 ] || { echo "::error::unexpected rows: total $TOTAL (20), error $ERRORS (5), info $INFOS (15), lambda name $NAMED (20)"; exit 1; } - mkdir -p out + [ "$TOTAL" = 20 ] && [ "$ERRORS" = 5 ] && [ "$INFOS" = 15 ] && [ "$NAMED" = 20 ] || fail "Unexpected rows: total $TOTAL (20), error $ERRORS (5), info $INFOS (15), lambda name $NAMED (20)" sql "SELECT * FROM remote(${COLLECTION}_logs) WHERE $SINCE AND position(raw, '$MARKER') > 0 ORDER BY dt LIMIT 2" > out/sample_log.json # Every stored row, field by field, against the event that produced it (events.json from the write step): # the level prefix stripped from the message, the level, the CloudWatch timestamp as dt, and the context @@ -229,7 +252,7 @@ jobs: ] as $problems | select($problems | length > 0) | "n=\($n): \($problems | join(", "))") | .[]' rows.jsonl) - [ -z "$PROBLEMS" ] || { echo "::error::stored rows differ from what the Lambda should have produced:"; echo "$PROBLEMS"; exit 1; } + [ -z "$PROBLEMS" ] || fail "Stored rows differ from what the Lambda should have produced" "$PROBLEMS" { echo "### AWS CloudWatch Lambda E2E \`$RUN_ID\`" echo @@ -243,7 +266,11 @@ jobs: - name: "[Test] The Lambda logged no errors" run: | ERRORS=$(aws logs filter-log-events --log-group-name "/aws/lambda/$FUNCTION_NAME" --filter-pattern '?ERROR ?"Task timed out" ?Unhandled' --query 'events[].message' --output text) - [ -z "$ERRORS" ] || { echo "::error::the Lambda logged errors:"; echo "$ERRORS"; exit 1; } + if [ -n "$ERRORS" ]; then + echo "::error::the Lambda logged errors:"; echo "$ERRORS" + { echo; echo "❌ The Lambda logged errors:"; echo; echo '```'; head -20 <<<"$ERRORS"; echo '```'; } >> out/summary.md + exit 1 + fi echo "no errors in /aws/lambda/$FUNCTION_NAME" - name: "[Cleanup] Delete this run's resources in Better Stack and AWS (and stale ones)" @@ -279,15 +306,44 @@ jobs: - name: "[Report] Job summary" if: ${{ always() }} + env: + GH_TOKEN: ${{ github.token }} run: | - if [ -f out/summary.md ]; then cat out/summary.md >> "$GITHUB_STEP_SUMMARY"; else echo "No summary, the run failed before verification." >> "$GITHUB_STEP_SUMMARY"; fi + mkdir -p report + if [ -f out/summary.md ]; then + cp out/summary.md report/summary.md + else + # The read-back writes the summary however it ends, so a step before it failed (or the job was cancelled). + FAILED_STEP=$(gh api "repos/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID/attempts/$GITHUB_RUN_ATTEMPT/jobs" \ + --jq ".jobs[] | select(.runner_name == \"$RUNNER_NAME\") | .steps[] | select(.conclusion == \"failure\") | .name" | head -1) || true + { echo "### AWS CloudWatch Lambda E2E \`$RUN_ID\`"; echo; echo "❌ Stopped${FAILED_STEP:+ at **$FAILED_STEP**}, before anything was read back."; } > report/summary.md + fi + cat report/summary.md >> "$GITHUB_STEP_SUMMARY" + # The Linear issue gets the cleanup only when it left something behind. + grep -q '❌' out/cleanup.md 2>/dev/null && { echo; cat out/cleanup.md; } >> report/summary.md # Rows come back as JSONEachRow with the stored event as a JSON string in raw; render them as a pretty array. [ -f out/sample_log.json ] && { echo; echo '
sample_log.json'; echo; echo '```json'; jq -s 'map(if has("raw") then .raw |= (fromjson? // .) else . end)' out/sample_log.json; echo '```'; echo '
'; echo; } >> "$GITHUB_STEP_SUMMARY" [ -f out/cleanup.md ] && { echo; cat out/cleanup.md; } >> "$GITHUB_STEP_SUMMARY" true + - name: "[Report] Hand the summary to the Linear issue" + if: ${{ always() }} + uses: actions/upload-artifact@v7 + with: + name: e2e-summary-CloudWatch round trip + path: report/summary.md + retention-days: 7 + overwrite: true + notify-linear: if: ${{ failure() && (github.event_name == 'schedule' || (github.event_name == 'push' && github.ref == 'refs/heads/main')) }} needs: [test] + permissions: + contents: read + actions: read + checks: read uses: ./.github/workflows/notify_linear_failure.yml + with: + test_name: AWS Lambda via CloudWatch + job_summaries: true secrets: inherit diff --git a/.github/workflows/notify_linear_failure.yml b/.github/workflows/notify_linear_failure.yml index 529be28..49813ab 100644 --- a/.github/workflows/notify_linear_failure.yml +++ b/.github/workflows/notify_linear_failure.yml @@ -1,7 +1,19 @@ name: "Notify Linear on failure" +# A caller that sets neither input gets the plain issue: "E2E failure: " listing the failed jobs and steps. on: workflow_call: + inputs: + test_name: + description: "Names the test in the issue title instead of the workflow name" + type: string + default: "" + job_summaries: + description: >- + Adds each failed job's errors and summary to the issue. A job hands its summary over as an artifact named + "e2e-summary-" holding summary.md. The calling job needs checks: read for the errors. + type: boolean + default: false jobs: create-linear-issue: @@ -9,26 +21,56 @@ jobs: permissions: contents: read actions: read + checks: read steps: + - name: "Download the jobs' summaries" + if: ${{ inputs.job_summaries }} + continue-on-error: true + uses: actions/download-artifact@v8 + with: + pattern: e2e-summary-* + path: summaries + run-id: ${{ github.event.workflow_run.id || github.run_id }} + github-token: ${{ secrets.GITHUB_TOKEN }} - name: "Gather failure context" id: ctx env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} RUN_ID: ${{ github.event.workflow_run.id || github.run_id }} RUN_ATTEMPT: ${{ github.event.workflow_run.run_attempt || github.run_attempt }} + JOB_SUMMARIES: ${{ inputs.job_summaries }} run: | - FAILED=$(gh api \ + JOBS=$(gh api \ "repos/${{ github.repository }}/actions/runs/$RUN_ID/attempts/$RUN_ATTEMPT/jobs" \ --paginate \ - --jq '[.jobs[] + --jq '.jobs[] | select(.conclusion == "failure") - | "- **\(.name)** — failed at _\([.steps[] | select(.conclusion == "failure") | .name] | join(", "))_"] - | join("\n")') - + | {id, name, steps: ([.steps[] | select(.conclusion == "failure") | .name] | join(", "))} + | @json') + + FAILED="" + while read -r job; do + [ -n "$job" ] || continue + NAME=$(jq -r .name <<<"$job") + STEPS=$(jq -r .steps <<<"$job") + if [ "$JOB_SUMMARIES" != true ]; then + FAILED="$FAILED- **$NAME** — failed at _${STEPS}_"$'\n' + continue + fi + # The job's ::error:: lines, without the runner's closing "Process completed with exit code 1." + ERRORS=$(gh api "repos/${{ github.repository }}/check-runs/$(jq -r .id <<<"$job")/annotations" \ + --jq '.[] | select(.annotation_level == "failure" and (.message | startswith("Process completed with exit code") | not)) | "- \(.message | gsub("\n"; " "))"' || true) + FAILED="$FAILED### $NAME"$'\n\n'"Failed at **${STEPS}**"$'\n\n'"${ERRORS:+$ERRORS$'\n\n'}" + SUMMARY="summaries/e2e-summary-$NAME/summary.md" + [ ! -f "$SUMMARY" ] || FAILED="$FAILED$(cat "$SUMMARY")"$'\n\n' + done <<<"$JOBS" + if [ -z "$FAILED" ]; then FAILED="_(no failed jobs reported by the API yet)_" + elif [ "${#FAILED}" -gt 20000 ]; then + FAILED="${FAILED:0:20000}"$'\n\n'"_(cut at 20,000 characters, the rest is in the run)_" fi - + { echo "failed_jobs< Date: Thu, 22 Oct 2026 09:42:49 +0200 Subject: [PATCH 2/2] Fetch each failed job's summary by its artifact name download-artifact unpacks a lone matching artifact straight into its path, so in a run with one job the summary was never found. Also keep an annotations API error out of the issue, and keep the plain issue text byte-identical to before. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/notify_linear_failure.yml | 21 ++++++++------------- 1 file changed, 8 insertions(+), 13 deletions(-) diff --git a/.github/workflows/notify_linear_failure.yml b/.github/workflows/notify_linear_failure.yml index 49813ab..72f9f2d 100644 --- a/.github/workflows/notify_linear_failure.yml +++ b/.github/workflows/notify_linear_failure.yml @@ -11,27 +11,19 @@ on: job_summaries: description: >- Adds each failed job's errors and summary to the issue. A job hands its summary over as an artifact named - "e2e-summary-" holding summary.md. The calling job needs checks: read for the errors. + "e2e-summary-" holding summary.md. type: boolean default: false jobs: create-linear-issue: runs-on: ubuntu-latest + # A caller must grant checks: read as well (the errors come from the annotations), or this job cannot start. permissions: contents: read actions: read checks: read steps: - - name: "Download the jobs' summaries" - if: ${{ inputs.job_summaries }} - continue-on-error: true - uses: actions/download-artifact@v8 - with: - pattern: e2e-summary-* - path: summaries - run-id: ${{ github.event.workflow_run.id || github.run_id }} - github-token: ${{ secrets.GITHUB_TOKEN }} - name: "Gather failure context" id: ctx env: @@ -59,12 +51,15 @@ jobs: fi # The job's ::error:: lines, without the runner's closing "Process completed with exit code 1." ERRORS=$(gh api "repos/${{ github.repository }}/check-runs/$(jq -r .id <<<"$job")/annotations" \ - --jq '.[] | select(.annotation_level == "failure" and (.message | startswith("Process completed with exit code") | not)) | "- \(.message | gsub("\n"; " "))"' || true) + --jq '.[] | select(.annotation_level == "failure" and (.message | startswith("Process completed with exit code") | not)) | "- \(.message | gsub("\n"; " "))"') || ERRORS="" FAILED="$FAILED### $NAME"$'\n\n'"Failed at **${STEPS}**"$'\n\n'"${ERRORS:+$ERRORS$'\n\n'}" - SUMMARY="summaries/e2e-summary-$NAME/summary.md" - [ ! -f "$SUMMARY" ] || FAILED="$FAILED$(cat "$SUMMARY")"$'\n\n' + # A job that failed before writing its summary has no artifact and gets the errors alone. + SUMMARY_DIR="summaries/$(jq -r .id <<<"$job")" + gh run download "$RUN_ID" -R "${{ github.repository }}" -n "e2e-summary-$NAME" -D "$SUMMARY_DIR" >/dev/null 2>&1 || true + [ ! -f "$SUMMARY_DIR/summary.md" ] || FAILED="$FAILED$(cat "$SUMMARY_DIR/summary.md")"$'\n\n' done <<<"$JOBS" + while [[ "$FAILED" == *$'\n' ]]; do FAILED="${FAILED%$'\n'}"; done if [ -z "$FAILED" ]; then FAILED="_(no failed jobs reported by the API yet)_" elif [ "${#FAILED}" -gt 20000 ]; then