diff --git a/AGENTS.md b/AGENTS.md index 6620d9e..c6a9628 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -101,3 +101,6 @@ After adding or editing a caller workflow: - Machine-oriented index: `llms.txt` - Release notes: `CHANGELOG.md` - Real consumer: `LLazyEmail/_playing_with_lit` → `.github/workflows/render-email-action.yml` (explained in `docs/real-world-example.md`) + +## Failure annotations +A non-zero build, validation, or command exits with an `::error::` annotation. The annotation includes the first cause line from the command (for example `error TS2307: Cannot find module ...`), and the failure block reprints that cause plus the last 20 lines. diff --git a/README.md b/README.md index 12f389a..df4d21a 100644 --- a/README.md +++ b/README.md @@ -196,7 +196,7 @@ Your own `env:` on the job is passed through as usual, so secrets such as `NODE_ ## Failure handling -- A template that fails to build, fails validation, or writes an empty file fails the job with an error annotation naming it. +- A template that fails to build, fails validation, or writes an empty file fails the job with an error annotation naming it. The annotation also includes the first cause line from the command (for example `error TS2307: Cannot find module ...`), and the failure block reprints that cause plus the last lines of the command. - With `fail-fast: false` the other templates still render. The artifact, the job summary and the pull request comment are produced, then the job fails. `failed.txt` in the artifact and a **Failed to render** section in the comment list what broke. - If every template fails there is nothing to publish and the job fails immediately. - An email over `size-limit-kb` produces a warning annotation, not a failure. diff --git a/scripts/render.sh b/scripts/render.sh index b3ffc8d..f0e08f3 100755 --- a/scripts/render.sh +++ b/scripts/render.sh @@ -64,14 +64,62 @@ trim() { printf '%s' "$1" | tr -d '\r' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//' } +# Last failing command's cause line and tail. Set by run_captured. +FAILURE_CAUSE="" +FAILURE_TAIL="" + +# Pull the most useful line out of a failed command's log. +# Prefers compiler and module errors over a generic exit line. +extract_cause() { + local file=$1 line + line=$(grep -E 'error TS[0-9]+|Cannot find module|ERR_MODULE_NOT_FOUND|SyntaxError|TypeError|^Error:|error:' "$file" | head -1 || true) + if [ -z "$line" ]; then + line=$(grep -E 'failed|exited' "$file" | head -1 || true) + fi + # Annotations are one line. Collapse whitespace. + printf '%s' "$line" | tr '\n\r' ' ' | sed 's/[[:space:]]\{1,\}/ /g;s/^ //;s/ $//' +} + +# Run a command, stream its output, and keep the cause plus the last lines. +# Usage: run_captured command [args...] +run_captured() { + local log rc + log=$(mktemp) + set +e + "$@" >"$log" 2>&1 + rc=$? + set -e + cat "$log" + FAILURE_CAUSE=$(extract_cause "$log") + FAILURE_TAIL=$(tail -n 20 "$log") + rm -f "$log" + return "$rc" +} + # Print an ::error:: annotation followed by a short block that says what failed # and what to check next. $2 may contain \n sequences. +# When FAILURE_CAUSE is set, it is appended to the annotation so the check +# summary shows the real error (tsc, missing module, ...) without scrolling. explain_failure() { local what=$1 detail=$2 + local annotation=$what + if [ -n "$FAILURE_CAUSE" ]; then + annotation="$what: $FAILURE_CAUSE" + fi echo "" - echo "::error::${what}" + echo "::error::${annotation}" echo "----------------------------------------" printf '%b\n' "$detail" + if [ -n "$FAILURE_CAUSE" ]; then + echo "" + echo "Cause:" + echo " $FAILURE_CAUSE" + fi + if [ -n "$FAILURE_TAIL" ]; then + echo "" + echo "Last lines of the failed command:" + printf '%s\n' "$FAILURE_TAIL" + fi echo "----------------------------------------" echo "Common next checks:" if [ -n "$BUILD_COMMAND" ]; then @@ -148,13 +196,15 @@ render_one() { local input=$1 output=$2 FAIL_WHAT="" FAIL_DETAIL="" + FAILURE_CAUSE="" + FAILURE_TAIL="" export EMAIL_TEMPLATE_INPUT=$input export EMAIL_TEMPLATE_OUTPUT=$output if [ -n "$BUILD_COMMAND" ]; then echo " build-command: $BUILD_COMMAND" - if ! bash -c "$BUILD_COMMAND"; then + if ! run_captured bash -c "$BUILD_COMMAND"; then FAIL_WHAT="render failed for $input" - FAIL_DETAIL="The build-command exited non-zero while rendering:\n input: $input\n output: $output\n command: $BUILD_COMMAND\n\nThe command's own output is above. Fix the command or its dependencies, then re-run." + FAIL_DETAIL="The build-command exited non-zero while rendering:\n input: $input\n output: $output\n command: $BUILD_COMMAND\n\nThe cause is below. Fix the command or its dependencies, then re-run." return 1 fi else @@ -165,15 +215,15 @@ render_one() { ARGS+=("${EXTRA[@]}") fi echo " script: $PM run $BUILD_SCRIPT ${ARGS[*]}" - if ! run_script "$BUILD_SCRIPT" "${ARGS[@]}"; then + if ! run_captured run_script "$BUILD_SCRIPT" "${ARGS[@]}"; then FAIL_WHAT="render failed for $input" - FAIL_DETAIL="$PM run $BUILD_SCRIPT exited non-zero while rendering:\n input: $input\n output: $output\n\nThe script's own output is above (for example a tsc error, a missing template, or a non-zero exit from the node process). Fix the consumer script or its dependencies, then re-run." + FAIL_DETAIL="$PM run $BUILD_SCRIPT exited non-zero while rendering:\n input: $input\n output: $output\n\nThe cause is below (for example a tsc error, a missing template, or a non-zero exit from the node process). Fix the consumer script or its dependencies, then re-run." return 1 fi fi - if ! validate_one "$output"; then + if ! run_captured validate_one "$output"; then FAIL_WHAT="validation failed for $output" - FAIL_DETAIL="Validation exited non-zero on:\n $output\n\nCheck the validation output above." + FAIL_DETAIL="Validation exited non-zero on:\n $output\n\nThe cause is below." return 1 fi if [ ! -s "$output" ]; then @@ -196,8 +246,8 @@ if [ -n "$COMMAND" ]; then exit 1 fi echo "running command: $COMMAND" - if ! bash -c "$COMMAND"; then - explain_failure "command mode failed" "The command exited non-zero:\n $COMMAND\n\nNo HTML was indexed. Check the command output above." + if ! run_captured bash -c "$COMMAND"; then + explain_failure "command mode failed" "The command exited non-zero:\n $COMMAND\n\nNo HTML was indexed. The cause is below." exit 1 fi outdir=$ARTIFACT_DIRECTORY diff --git a/tests/render.test.sh b/tests/render.test.sh index 8277758..fc4d611 100755 --- a/tests/render.test.sh +++ b/tests/render.test.sh @@ -211,6 +211,19 @@ render SINGLE_INPUT=src/newsletter.md SINGLE_OUTPUT=generated/n.html BUILD_COMMA assert_contains "build-command failure names the command" "$LOG" "The build-command exited non-zero" assert_contains "build-command failure gives command-specific advice" "$LOG" "Does the build-command run on its own" +new_project +cat > "$PROJ/fail-tsc.js" << 'EOF' +console.error("scripts/samples/hackernoon/hackernoon-data.ts(1,42): error TS2307: Cannot find module '../../../src/types.js'") +process.exit(1) +EOF +# Use a relative path inside the project. An absolute $PROJ path is a Git Bash +# path that Node on Windows cannot open (ENOENT on D:\tmp\...\package.json). +(cd "$PROJ" && node -e 'const fs=require("fs"); const p=JSON.parse(fs.readFileSync("package.json","utf8")); p.scripts["fail-tsc"]="node fail-tsc.js"; fs.writeFileSync("package.json", JSON.stringify(p,null,2));') +render SINGLE_INPUT=src/newsletter.md SINGLE_OUTPUT=generated/n.html BUILD_SCRIPT=fail-tsc +assert_eq "tsc-style failure fails the render" 1 "$RC" +assert_contains "annotation includes the tsc cause" "$LOG" "::error::render failed for src/newsletter.md: scripts/samples/hackernoon/hackernoon-data.ts(1,42): error TS2307: Cannot find module" +assert_contains "failure block repeats the cause" "$LOG" "Cause:" + new_project render SINGLE_INPUT=src/newsletter.md SINGLE_OUTPUT=generated/n.html BUILD_COMMAND='true' VALIDATE_SCRIPT= assert_eq "a build that writes nothing fails" 1 "$RC"