feat(autoupdate): report failing updates with their test output

A failing update has no PR, so its test output had nowhere to go. Capture
the output of the Go and Node steps. On failure, write it as a comment on
one sticky issue, one comment per branch, updated on every run. A final
job removes comments for branches that are gone and closes the issue when
no update fails. The step is best effort and also writes the run summary.
This commit is contained in:
2026-10-03 17:05:17 +01:00
parent dc0437151c
commit 94d1fe3409
2 changed files with 109 additions and 19 deletions
+105 -18
View File
@@ -80,6 +80,7 @@ concurrency:
env: env:
BATCH_BRANCH: deps-autoupdate/batch BATCH_BRANCH: deps-autoupdate/batch
ISSUE_TITLE: Dependency updates failing tests
jobs: jobs:
renovate: renovate:
@@ -184,6 +185,7 @@ jobs:
timeout-minutes: 60 timeout-minutes: 60
permissions: permissions:
contents: write contents: write
issues: write
pull-requests: write pull-requests: write
strategy: strategy:
fail-fast: false fail-fast: false
@@ -269,9 +271,12 @@ jobs:
if: steps.merge.outputs.skip != 'true' && steps.detect.outputs.go == 'true' if: steps.merge.outputs.skip != 'true' && steps.detect.outputs.go == 'true'
working-directory: ${{ inputs.go-working-directory }} working-directory: ${{ inputs.go-working-directory }}
run: | run: |
go mod verify set -o pipefail
go build ./... {
go test -race -cover ./... go mod verify
go build ./...
go test -race -cover ./...
} 2>&1 | tee -a "$RUNNER_TEMP/test-output.log"
- name: Setup Node.js - name: Setup Node.js
if: steps.merge.outputs.skip != 'true' && steps.detect.outputs.node == 'true' if: steps.merge.outputs.skip != 'true' && steps.detect.outputs.node == 'true'
@@ -285,20 +290,23 @@ jobs:
env: env:
PM: ${{ steps.detect.outputs.pm }} PM: ${{ steps.detect.outputs.pm }}
run: | run: |
# The lockfile must match what Renovate wrote: install without updating it. set -o pipefail
case "$PM" in {
pnpm | yarn) # The lockfile must match what Renovate wrote: install without updating it.
command -v corepack >/dev/null || npm install --global corepack case "$PM" in
corepack enable "$PM" pnpm | yarn)
;; command -v corepack >/dev/null || npm install --global corepack
esac corepack enable "$PM"
case "$PM" in ;;
pnpm) pnpm install --frozen-lockfile ;; esac
yarn) yarn install --immutable ;; case "$PM" in
npm) pnpm) pnpm install --frozen-lockfile ;;
if [ -f package-lock.json ]; then npm ci; else npm install; fi yarn) yarn install --immutable ;;
;; npm)
esac if [ -f package-lock.json ]; then npm ci; else npm install; fi
;;
esac
} 2>&1 | tee -a "$RUNNER_TEMP/test-output.log"
- name: Run Node scripts - name: Run Node scripts
if: steps.merge.outputs.skip != 'true' && steps.detect.outputs.node == 'true' if: steps.merge.outputs.skip != 'true' && steps.detect.outputs.node == 'true'
@@ -308,8 +316,11 @@ jobs:
SCRIPTS: ${{ inputs.node-scripts }} SCRIPTS: ${{ inputs.node-scripts }}
REQUIRE_TESTS: ${{ inputs.require-node-tests }} REQUIRE_TESTS: ${{ inputs.require-node-tests }}
run: | run: |
set -o pipefail
log="$RUNNER_TEMP/test-output.log"
if [ "$REQUIRE_TESTS" = true ] && ! jq -e '.scripts.test' package.json >/dev/null; then if [ "$REQUIRE_TESTS" = true ] && ! jq -e '.scripts.test' package.json >/dev/null; then
echo "::error::package.json has no test script. Add one, or set require-node-tests to false." echo "::error::package.json has no test script. Add one, or set require-node-tests to false."
echo "package.json has no test script, so the update was not tested." >>"$log"
exit 1 exit 1
fi fi
IFS=',' read -r -a scripts <<<"$SCRIPTS" IFS=',' read -r -a scripts <<<"$SCRIPTS"
@@ -318,7 +329,7 @@ jobs:
[ -n "$script" ] || continue [ -n "$script" ] || continue
if jq -e --arg s "$script" '.scripts[$s]' package.json >/dev/null; then if jq -e --arg s "$script" '.scripts[$s]' package.json >/dev/null; then
echo "::group::$PM run $script" echo "::group::$PM run $script"
"$PM" run "$script" "$PM" run "$script" 2>&1 | tee -a "$log"
echo "::endgroup::" echo "::endgroup::"
else else
echo "::notice::No '$script' script, skipping." echo "::notice::No '$script' script, skipping."
@@ -356,9 +367,85 @@ jobs:
- name: Report the failure - name: Report the failure
if: failure() if: failure()
# Best effort: the summary is always written, the issue needs issues: write.
continue-on-error: true
env:
HEAD_SHA: ${{ steps.merge.outputs.head }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: | run: |
echo "The tests failed for \`$BRANCH\`, so no PR was opened. Renovate retries on the next run." >> "$GITHUB_STEP_SUMMARY" echo "The tests failed for \`$BRANCH\`, so no PR was opened. Renovate retries on the next run." >> "$GITHUB_STEP_SUMMARY"
log="$RUNNER_TEMP/test-output.log"
esc=$(printf '\033')
marker="<!-- renovate-autoupdate:$BRANCH -->"
body="$RUNNER_TEMP/failure-comment.md"
{
echo "$marker"
echo "### \`$BRANCH\` failed its tests"
echo
echo "Commit \`${HEAD_SHA:0:8}\`, [workflow run]($RUN_URL), $(date -u '+%Y-%m-%d %H:%M') UTC. No PR was opened."
echo
echo "<details open><summary>Test output, last lines</summary>"
echo
echo '`````'
if [ -s "$log" ]; then
sed -E "s/${esc}\[[0-9;]*[A-Za-z]//g" "$log" | tail -c 50000
else
echo "No output was captured. See the workflow run."
fi
echo '`````'
echo
echo "</details>"
} > "$body"
issue=$(gh issue list --repo "$GITHUB_REPOSITORY" --state open --search "\"$ISSUE_TITLE\" in:title" --json number,title \
--jq "[.[] | select(.title == \"$ISSUE_TITLE\")][0].number // empty")
if [ -z "$issue" ]; then
url=$(gh issue create --repo "$GITHUB_REPOSITORY" --title "$ISSUE_TITLE" \
--body "Updates that failed their tests, so no PR was opened. Each comment holds the output for one branch. Fix the cause on the branch or in the code. The workflow retries on every run, merges an update once it passes, and closes this issue when none fail.")
issue=${url##*/}
fi
id=$(gh api "repos/$GITHUB_REPOSITORY/issues/$issue/comments" --paginate \
--jq ".[] | select(.body | contains(\"$marker\")) | .id" | head -n 1)
if [ -n "$id" ]; then
gh api -X PATCH "repos/$GITHUB_REPOSITORY/issues/comments/$id" -F body=@"$body" >/dev/null
else
gh issue comment "$issue" --repo "$GITHUB_REPOSITORY" --body-file "$body"
fi
tidy-failures:
name: Tidy the failure issue
needs: [renovate, test-and-merge]
if: always() && needs.renovate.result == 'success'
runs-on: ${{ fromJSON(startsWith(inputs.runner || vars.SHARED_ACTIONS_RUNNER || 'ubuntu-latest', '{') && (inputs.runner || vars.SHARED_ACTIONS_RUNNER || 'ubuntu-latest') || format('"{0}"', inputs.runner || vars.SHARED_ACTIONS_RUNNER || 'ubuntu-latest')) }}
timeout-minutes: 10
permissions:
issues: write
contents: read
steps:
- name: Remove fixed updates and close the issue when none fail
continue-on-error: true
env:
GH_TOKEN: ${{ github.token }}
run: |
issue=$(gh issue list --repo "$GITHUB_REPOSITORY" --state open --search "\"$ISSUE_TITLE\" in:title" --json number,title \
--jq "[.[] | select(.title == \"$ISSUE_TITLE\")][0].number // empty")
[ -n "$issue" ] || exit 0
branches=$(gh api --paginate "repos/$GITHUB_REPOSITORY/branches" --jq '.[].name')
remaining=0
while IFS=$'\t' read -r id branch; do
[ -n "$id" ] || continue
if printf '%s\n' "$branches" | grep -Fxq "$branch"; then
remaining=$((remaining + 1))
else
gh api -X DELETE "repos/$GITHUB_REPOSITORY/issues/comments/$id"
fi
done < <(gh api "repos/$GITHUB_REPOSITORY/issues/$issue/comments" --paginate \
--jq '.[] | select(.body | startswith("<!-- renovate-autoupdate:")) | [.id, (.body | split("\n")[0] | sub("^<!-- renovate-autoupdate:"; "") | sub(" -->$"; ""))] | @tsv')
if [ "$remaining" -eq 0 ]; then
gh issue close "$issue" --repo "$GITHUB_REPOSITORY" --comment "Every dependency update now passes its tests."
fi
release: release:
name: Dispatch the release name: Dispatch the release
needs: [renovate, test-and-merge] needs: [renovate, test-and-merge]
+4 -1
View File
@@ -106,7 +106,7 @@ Daily dependency updates for Go and npm/pnpm/yarn projects, built on Renovate. I
1. Renovate upgrades everything that can be upgraded on branches named `deps-autoupdate/*` and opens no PR. All minor and patch updates (direct and indirect Go modules, npm, pnpm and yarn packages) go on `deps-autoupdate/batch`. Each major update gets its own branch. 1. Renovate upgrades everything that can be upgraded on branches named `deps-autoupdate/*` and opens no PR. All minor and patch updates (direct and indirect Go modules, npm, pnpm and yarn packages) go on `deps-autoupdate/batch`. Each major update gets its own branch.
2. For each branch, one at a time, the workflow merges it into the default branch locally and runs the tests on that result: `go build` and `go test -race -cover` for Go, the `package.json` scripts for Node. 2. For each branch, one at a time, the workflow merges it into the default branch locally and runs the tests on that result: `go build` and `go test -race -cover` for Go, the `package.json` scripts for Node.
3. If the tests pass, it opens a PR and squash-merges it. If they fail, no PR is ever opened. The failure shows in the run summary, and Renovate retries on the next run. 3. If the tests pass, it opens a PR and squash-merges it. If they fail, no PR is opened. The test output goes into a comment on one sticky issue (see below), and Renovate retries on the next run.
4. If anything merged and `release-workflow` is set, it dispatches the release once. 4. If anything merged and `release-workflow` is set, it dispatches the release once.
```yaml ```yaml
@@ -120,6 +120,7 @@ on:
permissions: permissions:
actions: write # only to dispatch release-workflow actions: write # only to dispatch release-workflow
contents: write contents: write
issues: write # only to report failing updates
pull-requests: write pull-requests: write
jobs: jobs:
@@ -149,6 +150,8 @@ The workflow finds `go.mod` and `package.json` itself and tests what exists. A r
| `lfs` | `false` | Git LFS checkout | | `lfs` | `false` | Git LFS checkout |
| `runner` | | Runner label or runner group, as in the other workflows | | `runner` | | Runner label or runner group, as in the other workflows |
**Failing updates.** The last 50 KB of the test output goes into a comment on one issue titled "Dependency updates failing tests". Each failing branch has its own comment, updated on every run, and a header with the commit and the run link. When a branch passes and merges, its comment is removed. When no failing branch is left, the issue closes. The output is also in the run summary. Without `issues: write` the run still works, but the issue is skipped.
**Branches run one at a time**, so each one is tested on top of the ones merged before it. A branch that conflicts with the default branch is skipped, and Renovate rebases it on the next run. **Branches run one at a time**, so each one is tested on top of the ones merged before it. A branch that conflicts with the default branch is skipped, and Renovate rebases it on the next run.
**Package manager.** pnpm is used when `pnpm-lock.yaml` exists, yarn for `yarn.lock`, otherwise npm. The install step never changes the lockfile (`--frozen-lockfile`, `--immutable`, `npm ci`), so a lockfile that does not match `package.json` fails the run. pnpm and yarn run through Corepack, so set `packageManager` in `package.json`. **Package manager.** pnpm is used when `pnpm-lock.yaml` exists, yarn for `yarn.lock`, otherwise npm. The install step never changes the lockfile (`--frozen-lockfile`, `--immutable`, `npm ci`), so a lockfile that does not match `package.json` fails the run. pnpm and yarn run through Corepack, so set `packageManager` in `package.json`.