From ac3bd20aa324a823b12f0d464ecd8fe1b98e92e2 Mon Sep 17 00:00:00 2001 From: serrrfirat Date: Wed, 6 May 2026 13:17:08 +0200 Subject: [PATCH 1/2] ci: add nightly failure issue alerts --- .github/scripts/nightly-alert-issue.sh | 186 ++++++++++++++++++++ .github/scripts/nightly-alert-issue.test.sh | 124 +++++++++++++ .github/workflows/e2e.yml | 22 +++ .github/workflows/nightly-deep-ci.yml | 22 +++ 4 files changed, 354 insertions(+) create mode 100755 .github/scripts/nightly-alert-issue.sh create mode 100755 .github/scripts/nightly-alert-issue.test.sh diff --git a/.github/scripts/nightly-alert-issue.sh b/.github/scripts/nightly-alert-issue.sh new file mode 100755 index 00000000000..2ffd9f41342 --- /dev/null +++ b/.github/scripts/nightly-alert-issue.sh @@ -0,0 +1,186 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Create, update, or close a GitHub issue for a scheduled nightly workflow. +# Required env: +# GH_TOKEN, REPO, ALERT_WORKFLOW_NAME, ALERT_ISSUE_TITLE, ALERT_RESULT +# Optional env: +# ALERT_RUN_ID, ALERT_RUN_URL, ALERT_SHA, MAX_EXCERPT_LINES + +require_env() { + local name="$1" + if [[ -z "${!name:-}" ]]; then + echo "Required environment variable ${name} is not set" >&2 + exit 2 + fi +} + +strip_ansi() { + # Keep this sed expression POSIX-ish for the GitHub ubuntu runner. + sed -E $'s/\x1B\[[0-9;?]*[ -/]*[@-~]//g' +} + +extract_failure_excerpt() { + local log_file="$1" + local output_file="$2" + local max_lines="${3:-${MAX_EXCERPT_LINES:-220}}" + local cleaned_log + cleaned_log="$(mktemp)" + + if [[ ! -s "$log_file" ]]; then + echo "No failed-job logs were available from GitHub Actions." > "$output_file" + return 0 + fi + + strip_ansi < "$log_file" > "$cleaned_log" + + # Prefer high-signal lines from pytest, Rust/cargo, GitHub Actions, runner + # infrastructure, and common timeout/disk failures. Keep the excerpt bounded so + # repeated nightly failures do not create unreadably large issue comments. + grep -nEi \ + '(^|[[:space:]])(FAILED|ERROR|FAILURES|failures:|test result: FAILED|panicked at|thread .+ panicked|No space left|timed out|timeout|Process completed with exit code|::error|Error:|Traceback|AssertionError|short test summary info|cargo (test|nextest|insta)|pytest|failed to|could not|cannot|killed|segmentation fault|signal:)' \ + "$cleaned_log" \ + | head -n "$max_lines" \ + > "$output_file" || true + + if [[ ! -s "$output_file" ]]; then + { + echo "No high-signal failure lines matched; showing the last 120 failed-log lines instead." + echo + tail -n 120 "$cleaned_log" + } > "$output_file" + fi + + rm -f "$cleaned_log" +} + +find_open_issue() { + local title="$1" + gh issue list \ + --repo "$REPO" \ + --state open \ + --search "${title} in:title" \ + --json number,title \ + --jq 'map(select(.title == env.ALERT_ISSUE_TITLE))[0].number // empty' +} + +write_failure_body() { + local body_file="$1" + local jobs_file="$2" + local excerpt_file="$3" + local log_error_file="$4" + + { + echo "❌ ${ALERT_WORKFLOW_NAME} scheduled run failed." + echo + echo "- Workflow: ${ALERT_WORKFLOW_NAME}" + echo "- Result: ${ALERT_RESULT}" + echo "- Run: ${ALERT_RUN_URL}" + echo "- Commit: ${ALERT_SHA}" + echo "- Attempt: ${GITHUB_RUN_ATTEMPT:-1}" + echo "- Reported at: $(date -u +'%Y-%m-%d %H:%M:%S UTC')" + echo + echo "## Failed jobs" + if [[ -s "$jobs_file" ]]; then + cat "$jobs_file" + else + echo "No failed job metadata was available from the Actions API. Check the run link above." + fi + echo + if [[ -s "$log_error_file" ]]; then + echo "## Log retrieval notes" + echo + echo "The alert job could not retrieve full failed-job logs with \`gh run view --log-failed\`. The run link and failed job links above are still authoritative." + echo + echo '```text' + head -n 40 "$log_error_file" | sed 's/```/` ` `/g' + echo '```' + echo + fi + echo "## Failure excerpt" + echo + echo '```text' + sed 's/```/` ` `/g' "$excerpt_file" + echo '```' + echo + echo "This issue is updated in place on repeated failures to avoid notification spam. If the next scheduled run passes, the nightly alert job will close this issue automatically." + } > "$body_file" +} + +write_recovery_body() { + local body_file="$1" + { + echo "✅ ${ALERT_WORKFLOW_NAME} recovered on the latest scheduled run." + echo + echo "- Run: ${ALERT_RUN_URL}" + echo "- Commit: ${ALERT_SHA}" + echo "- Reported at: $(date -u +'%Y-%m-%d %H:%M:%S UTC')" + } > "$body_file" +} + +main() { + require_env GH_TOKEN + require_env REPO + require_env ALERT_WORKFLOW_NAME + require_env ALERT_ISSUE_TITLE + require_env ALERT_RESULT + + export ALERT_ISSUE_TITLE + + ALERT_RUN_ID="${ALERT_RUN_ID:-${GITHUB_RUN_ID:-}}" + ALERT_RUN_URL="${ALERT_RUN_URL:-${GITHUB_SERVER_URL:-https://github.com}/${GITHUB_REPOSITORY:-$REPO}/actions/runs/${ALERT_RUN_ID}}" + ALERT_SHA="${ALERT_SHA:-${GITHUB_SHA:-unknown}}" + + if [[ -z "$ALERT_RUN_ID" ]]; then + echo "ALERT_RUN_ID or GITHUB_RUN_ID is required" >&2 + exit 2 + fi + + local issue_number + issue_number="$(find_open_issue "$ALERT_ISSUE_TITLE")" + + if [[ "$ALERT_RESULT" == "success" ]]; then + if [[ -n "$issue_number" ]]; then + local recovery_body + recovery_body="$(mktemp)" + write_recovery_body "$recovery_body" + gh issue close "$issue_number" --repo "$REPO" --comment "$(cat "$recovery_body")" + rm -f "$recovery_body" + else + echo "${ALERT_WORKFLOW_NAME} succeeded and no open alert issue exists." + fi + exit 0 + fi + + local tmp_dir log_file log_error failed_jobs excerpt body + tmp_dir="$(mktemp -d)" + log_file="${tmp_dir}/failed.log" + log_error="${tmp_dir}/failed-log-error.txt" + failed_jobs="${tmp_dir}/failed-jobs.md" + excerpt="${tmp_dir}/excerpt.txt" + body="${tmp_dir}/issue.md" + + if ! gh run view "$ALERT_RUN_ID" --repo "$REPO" --log-failed > "$log_file" 2> "$log_error"; then + echo "Warning: failed to retrieve failed-job logs for run ${ALERT_RUN_ID}" >&2 + fi + + gh api "repos/${REPO}/actions/runs/${ALERT_RUN_ID}/jobs?per_page=100" \ + --jq '.jobs[] | select(.status == "completed" and .conclusion != "success" and .conclusion != "skipped") | "- \(.name) (`\(.conclusion // "unknown")`): \(.html_url)"' \ + > "$failed_jobs" || true + + extract_failure_excerpt "$log_file" "$excerpt" "${MAX_EXCERPT_LINES:-220}" + write_failure_body "$body" "$failed_jobs" "$excerpt" "$log_error" + + if [[ -n "$issue_number" ]]; then + gh issue edit "$issue_number" --repo "$REPO" --body-file "$body" + echo "Updated existing nightly alert issue #${issue_number}." + else + gh issue create --repo "$REPO" --title "$ALERT_ISSUE_TITLE" --body-file "$body" + fi + + rm -rf "$tmp_dir" +} + +if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then + main "$@" +fi diff --git a/.github/scripts/nightly-alert-issue.test.sh b/.github/scripts/nightly-alert-issue.test.sh new file mode 100755 index 00000000000..c1710697bd1 --- /dev/null +++ b/.github/scripts/nightly-alert-issue.test.sh @@ -0,0 +1,124 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=nightly-alert-issue.sh +source "${SCRIPT_DIR}/nightly-alert-issue.sh" + +assert_contains() { + local file="$1" + local needle="$2" + if ! grep -Fq "$needle" "$file"; then + echo "Expected ${file} to contain: ${needle}" >&2 + echo "--- ${file}" >&2 + cat "$file" >&2 + exit 1 + fi +} + +workdir="$(mktemp -d)" +trap 'rm -rf "${workdir}"' EXIT + +cat > "${workdir}/failed.log" <<'LOG' +Build step noise +E2E (features) Run E2E tests (features) FAILED tests/e2e/scenarios/test_tool_approval.py::test_approval_flow - AssertionError: expected approval dialog +Rust tests Run Tests thread 'runtime::manager::tests::stop_thread_works' panicked at crates/ironclaw_engine/src/runtime/manager.rs:123:5 +Coverage Generate coverage Error: No space left on device +Runner Complete job Process completed with exit code 101. +LOG + +extract_failure_excerpt "${workdir}/failed.log" "${workdir}/excerpt.txt" 20 +assert_contains "${workdir}/excerpt.txt" "FAILED tests/e2e/scenarios/test_tool_approval.py::test_approval_flow" +assert_contains "${workdir}/excerpt.txt" "panicked at crates/ironclaw_engine/src/runtime/manager.rs" +assert_contains "${workdir}/excerpt.txt" "No space left on device" +assert_contains "${workdir}/excerpt.txt" "Process completed with exit code 101" + +cat > "${workdir}/quiet.log" <<'LOG' +ordinary line one +ordinary line two +LOG + +extract_failure_excerpt "${workdir}/quiet.log" "${workdir}/fallback.txt" 20 +assert_contains "${workdir}/fallback.txt" "No high-signal failure lines matched" +assert_contains "${workdir}/fallback.txt" "ordinary line two" + +cat > "${workdir}/jobs.md" <<'JOBS' +- E2E (features) (`failure`): https://github.example/jobs/1 +JOBS +cat > "${workdir}/log-error.txt" <<'ERR' +HTTP 404: logs are not ready yet +ERR + +ALERT_WORKFLOW_NAME="Nightly E2E" \ +ALERT_RESULT="failure" \ +ALERT_RUN_URL="https://github.example/runs/1" \ +ALERT_SHA="abc123" \ +write_failure_body "${workdir}/body.md" "${workdir}/jobs.md" "${workdir}/excerpt.txt" "${workdir}/log-error.txt" +assert_contains "${workdir}/body.md" "Log retrieval notes" +assert_contains "${workdir}/body.md" "HTTP 404: logs are not ready yet" +assert_contains "${workdir}/body.md" "updated in place on repeated failures" + +mkdir -p "${workdir}/bin" +cat > "${workdir}/bin/gh" <<'GH' +#!/usr/bin/env bash +set -euo pipefail +printf '%s\n' "$*" >> "${FAKE_GH_CALLS}" +case "${1:-} ${2:-}" in + "issue list") + if [[ "${FAKE_EXISTING_ISSUE:-}" == "1" ]]; then + echo "42" + fi + ;; + "run view") + echo "FAILED tests/e2e/scenarios/test_chat.py::test_chat - AssertionError" + ;; + "api repos/example/repo/actions/runs/99/jobs?per_page=100") + echo "- E2E (core) (\`failure\`): https://github.example/jobs/1" + ;; + "issue edit"|"issue create"|"issue close") + ;; + *) + echo "unexpected gh call: $*" >&2 + exit 1 + ;; +esac +GH +chmod +x "${workdir}/bin/gh" + +run_alert_script() { + local result="$1" + PATH="${workdir}/bin:${PATH}" \ + FAKE_GH_CALLS="${workdir}/gh-calls.txt" \ + FAKE_EXISTING_ISSUE="${FAKE_EXISTING_ISSUE:-}" \ + GH_TOKEN="token" \ + REPO="example/repo" \ + GITHUB_RUN_ID="99" \ + GITHUB_RUN_ATTEMPT="1" \ + GITHUB_SERVER_URL="https://github.example" \ + GITHUB_REPOSITORY="example/repo" \ + GITHUB_SHA="abc123" \ + ALERT_WORKFLOW_NAME="Nightly E2E" \ + ALERT_ISSUE_TITLE="Nightly E2E failed" \ + ALERT_RESULT="$result" \ + "${SCRIPT_DIR}/nightly-alert-issue.sh" +} + +: > "${workdir}/gh-calls.txt" +FAKE_EXISTING_ISSUE="1" run_alert_script "failure" +assert_contains "${workdir}/gh-calls.txt" "issue edit 42" +if grep -Fq "issue comment" "${workdir}/gh-calls.txt"; then + echo "Repeated failures must update the issue body, not add comment spam" >&2 + cat "${workdir}/gh-calls.txt" >&2 + exit 1 +fi + +: > "${workdir}/gh-calls.txt" +FAKE_EXISTING_ISSUE="1" run_alert_script "success" +assert_contains "${workdir}/gh-calls.txt" "issue close 42" +if grep -Fq "issue comment" "${workdir}/gh-calls.txt"; then + echo "Recovery should close with one close comment, not a separate comment" >&2 + cat "${workdir}/gh-calls.txt" >&2 + exit 1 +fi + +echo "nightly-alert-issue tests passed" diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index a94232ab7c9..83628c579e9 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -126,3 +126,25 @@ jobs: echo "One or more E2E jobs failed" exit 1 fi + + nightly-alert: + name: Nightly E2E Alert + runs-on: ubuntu-latest + if: always() && github.event_name == 'schedule' + permissions: + actions: read + contents: read + issues: write + needs: [e2e] + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + persist-credentials: false + - name: Report nightly E2E result + env: + GH_TOKEN: ${{ github.token }} + REPO: ${{ github.repository }} + ALERT_WORKFLOW_NAME: Nightly E2E + ALERT_ISSUE_TITLE: Nightly E2E failed + ALERT_RESULT: ${{ needs.e2e.result }} + run: .github/scripts/nightly-alert-issue.sh diff --git a/.github/workflows/nightly-deep-ci.yml b/.github/workflows/nightly-deep-ci.yml index 54280f9aabc..021d5cfbc1d 100644 --- a/.github/workflows/nightly-deep-ci.yml +++ b/.github/workflows/nightly-deep-ci.yml @@ -35,3 +35,25 @@ jobs: with: ref: main include_docker: false + + nightly-alert: + name: Nightly Deep CI Alert + runs-on: ubuntu-latest + if: always() && github.event_name == 'schedule' + permissions: + actions: read + contents: read + issues: write + needs: [deterministic-deep-tests] + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + persist-credentials: false + - name: Report nightly deep CI result + env: + GH_TOKEN: ${{ github.token }} + REPO: ${{ github.repository }} + ALERT_WORKFLOW_NAME: Nightly Deep CI + ALERT_ISSUE_TITLE: Nightly Deep CI failed + ALERT_RESULT: ${{ needs.deterministic-deep-tests.result }} + run: .github/scripts/nightly-alert-issue.sh From f6f0267127a17cc57ea34b0b106f6bb5b2084022 Mon Sep 17 00:00:00 2001 From: serrrfirat Date: Wed, 6 May 2026 13:29:10 +0200 Subject: [PATCH 2/2] ci: harden nightly alert reporting --- .github/scripts/nightly-alert-issue.sh | 51 +++++++++++++++++++-- .github/scripts/nightly-alert-issue.test.sh | 32 +++++++++++++ .github/workflows/nightly-deep-ci.yml | 2 +- 3 files changed, 79 insertions(+), 6 deletions(-) diff --git a/.github/scripts/nightly-alert-issue.sh b/.github/scripts/nightly-alert-issue.sh index 2ffd9f41342..99051d0bfa8 100755 --- a/.github/scripts/nightly-alert-issue.sh +++ b/.github/scripts/nightly-alert-issue.sh @@ -20,12 +20,48 @@ strip_ansi() { sed -E $'s/\x1B\[[0-9;?]*[ -/]*[@-~]//g' } +truncate_lines() { + local max_chars="$1" + awk -v max_chars="$max_chars" ' + length($0) > max_chars { print substr($0, 1, max_chars) "... [line truncated]"; next } + { print } + ' +} + +truncate_file_chars() { + local input_file="$1" + local output_file="$2" + local max_chars="$3" + local label="${4:-content}" + awk -v max_chars="$max_chars" -v label="$label" ' + BEGIN { used = 0 } + { + line = $0 "\n" + line_len = length(line) + if (used + line_len > max_chars) { + remaining = max_chars - used + if (remaining > 0) { + printf "%s", substr(line, 1, remaining) + } + printf "\n... [%s truncated to %s characters]\n", label, max_chars + exit + } + printf "%s", line + used += line_len + } + ' "$input_file" > "$output_file" +} + extract_failure_excerpt() { local log_file="$1" local output_file="$2" local max_lines="${3:-${MAX_EXCERPT_LINES:-220}}" - local cleaned_log + local max_line_chars="${MAX_EXCERPT_LINE_CHARS:-2000}" + local max_excerpt_chars="${MAX_EXCERPT_CHARS:-50000}" + local cleaned_log raw_excerpt line_limited_excerpt cleaned_log="$(mktemp)" + raw_excerpt="$(mktemp)" + line_limited_excerpt="$(mktemp)" if [[ ! -s "$log_file" ]]; then echo "No failed-job logs were available from GitHub Actions." > "$output_file" @@ -41,17 +77,20 @@ extract_failure_excerpt() { '(^|[[:space:]])(FAILED|ERROR|FAILURES|failures:|test result: FAILED|panicked at|thread .+ panicked|No space left|timed out|timeout|Process completed with exit code|::error|Error:|Traceback|AssertionError|short test summary info|cargo (test|nextest|insta)|pytest|failed to|could not|cannot|killed|segmentation fault|signal:)' \ "$cleaned_log" \ | head -n "$max_lines" \ - > "$output_file" || true + > "$raw_excerpt" || true - if [[ ! -s "$output_file" ]]; then + if [[ ! -s "$raw_excerpt" ]]; then { echo "No high-signal failure lines matched; showing the last 120 failed-log lines instead." echo tail -n 120 "$cleaned_log" - } > "$output_file" + } > "$raw_excerpt" fi - rm -f "$cleaned_log" + truncate_lines "$max_line_chars" < "$raw_excerpt" > "$line_limited_excerpt" + truncate_file_chars "$line_limited_excerpt" "$output_file" "$max_excerpt_chars" "excerpt" + + rm -f "$cleaned_log" "$raw_excerpt" "$line_limited_excerpt" } find_open_issue() { @@ -170,6 +209,8 @@ main() { extract_failure_excerpt "$log_file" "$excerpt" "${MAX_EXCERPT_LINES:-220}" write_failure_body "$body" "$failed_jobs" "$excerpt" "$log_error" + truncate_file_chars "$body" "${body}.bounded" "${MAX_ISSUE_BODY_CHARS:-60000}" "issue body" + mv "${body}.bounded" "$body" if [[ -n "$issue_number" ]]; then gh issue edit "$issue_number" --repo "$REPO" --body-file "$body" diff --git a/.github/scripts/nightly-alert-issue.test.sh b/.github/scripts/nightly-alert-issue.test.sh index c1710697bd1..89de0cbd26e 100755 --- a/.github/scripts/nightly-alert-issue.test.sh +++ b/.github/scripts/nightly-alert-issue.test.sh @@ -42,6 +42,36 @@ extract_failure_excerpt "${workdir}/quiet.log" "${workdir}/fallback.txt" 20 assert_contains "${workdir}/fallback.txt" "No high-signal failure lines matched" assert_contains "${workdir}/fallback.txt" "ordinary line two" +python3 - <<'PY' "${workdir}/long.log" +from pathlib import Path +import sys +Path(sys.argv[1]).write_text('ERROR: ' + ('x' * 70000) + '\n') +PY +MAX_EXCERPT_LINE_CHARS=120 MAX_EXCERPT_CHARS=300 extract_failure_excerpt "${workdir}/long.log" "${workdir}/long-excerpt.txt" 20 +assert_contains "${workdir}/long-excerpt.txt" "[line truncated]" +long_excerpt_bytes="$(wc -c < "${workdir}/long-excerpt.txt" | tr -d ' ')" +if [[ "${long_excerpt_bytes}" -gt 360 ]]; then + echo "Expected long excerpt to stay bounded, got ${long_excerpt_bytes} bytes" >&2 + cat "${workdir}/long-excerpt.txt" >&2 + exit 1 +fi + +cat > "${workdir}/many.log" <<'LOG' +ERROR: first failure line with enough content to keep +ERROR: second failure line with enough content to keep +ERROR: third failure line with enough content to keep +ERROR: fourth failure line with enough content to keep +ERROR: fifth failure line with enough content to keep +LOG +MAX_EXCERPT_LINE_CHARS=1000 MAX_EXCERPT_CHARS=120 extract_failure_excerpt "${workdir}/many.log" "${workdir}/many-excerpt.txt" 20 +assert_contains "${workdir}/many-excerpt.txt" "[excerpt truncated to 120 characters]" +many_excerpt_bytes="$(wc -c < "${workdir}/many-excerpt.txt" | tr -d ' ')" +if [[ "${many_excerpt_bytes}" -gt 180 ]]; then + echo "Expected many-line excerpt to stay bounded, got ${many_excerpt_bytes} bytes" >&2 + cat "${workdir}/many-excerpt.txt" >&2 + exit 1 +fi + cat > "${workdir}/jobs.md" <<'JOBS' - E2E (features) (`failure`): https://github.example/jobs/1 JOBS @@ -57,6 +87,8 @@ write_failure_body "${workdir}/body.md" "${workdir}/jobs.md" "${workdir}/excerpt assert_contains "${workdir}/body.md" "Log retrieval notes" assert_contains "${workdir}/body.md" "HTTP 404: logs are not ready yet" assert_contains "${workdir}/body.md" "updated in place on repeated failures" +truncate_file_chars "${workdir}/body.md" "${workdir}/body-bounded.md" 240 "issue body" +assert_contains "${workdir}/body-bounded.md" "[issue body truncated to 240 characters]" mkdir -p "${workdir}/bin" cat > "${workdir}/bin/gh" <<'GH' diff --git a/.github/workflows/nightly-deep-ci.yml b/.github/workflows/nightly-deep-ci.yml index 021d5cfbc1d..ab457ac0b9a 100644 --- a/.github/workflows/nightly-deep-ci.yml +++ b/.github/workflows/nightly-deep-ci.yml @@ -33,7 +33,7 @@ jobs: name: Deterministic Deep Tests uses: ./.github/workflows/test.yml with: - ref: main + ref: ${{ github.sha }} include_docker: false nightly-alert: