name: Renovate Autoupdate # Renovate prepares branches with every Go and npm/pnpm/yarn update and opens no PR. # Each branch is merged into the default branch and tested. Only when the tests # pass does the workflow open a PR and merge it. Replaces go-autoupdate. # A renovate.json in the calling repository adds to these defaults. on: workflow_call: inputs: go-version: description: "Go version to use" required: false type: string default: ">=1.24" go-working-directory: description: "Directory that holds go.mod" required: false type: string default: "." node-version: description: "Node.js version to use" required: false type: string default: "22" node-working-directory: description: "Directory that holds package.json" required: false type: string default: "." node-scripts: description: "Comma-separated package.json scripts to run, in order. A script that does not exist is skipped." required: false type: string default: "lint,typecheck,test,build" require-node-tests: description: "Fail when a Node project has no test script, so an untested update never merges" required: false type: boolean default: true major-updates: description: "What to do with major updates: 'separate' gives each its own branch, tested and merged on its own, 'batch' puts them in the one batch, 'ignore' skips them." required: false type: string default: "separate" minimum-release-age: description: "Skip releases younger than this, for example '2 days'" required: false type: string default: "2 days" renovate-version: description: "Renovate version for the action" required: false type: string default: "44" release-workflow: description: "Workflow file to dispatch after a merge, for example release.yaml. It must have a workflow_dispatch trigger and the caller needs actions: write. Empty means no dispatch." required: false type: string default: "" admin-merge: description: "Merge with --admin, which bypasses branch protection. The workflow token cannot do this, so leave it off unless you pass a token that can." required: false type: boolean default: false lfs: description: "Enable Git LFS checkout (for repos with large files)" required: false type: boolean default: false runner: description: "Runner label, or JSON object such as {\"group\":\"ci\",\"labels\":[\"linux\"]} to select a runner group. Falls back to vars.SHARED_ACTIONS_RUNNER, then ubuntu-latest." required: false type: string default: "" concurrency: group: renovate-autoupdate-${{ github.repository }} cancel-in-progress: false env: BATCH_BRANCH: deps-autoupdate/batch ISSUE_TITLE: Dependency updates failing tests jobs: renovate: name: Prepare the updates 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: 45 permissions: contents: write pull-requests: write outputs: branches: ${{ steps.list.outputs.branches }} base-sha: ${{ steps.base.outputs.sha }} steps: - name: Record the default branch commit id: base env: GH_TOKEN: ${{ github.token }} run: | branch=$(gh api "repos/$GITHUB_REPOSITORY" --jq .default_branch) echo "sha=$(gh api "repos/$GITHUB_REPOSITORY/commits/$branch" --jq .sha)" >> "$GITHUB_OUTPUT" - name: Write Renovate config env: MIN_AGE: ${{ inputs.minimum-release-age }} MAJOR_UPDATES: ${{ inputs.major-updates }} CONFIG_FILE: ${{ runner.temp }}/renovate-autoupdate-config.json run: | jq -n \ --arg repo "$GITHUB_REPOSITORY" \ --arg age "$MIN_AGE" \ --arg branch_prefix "${BATCH_BRANCH%%/*}/" \ --arg majors "$MAJOR_UPDATES" \ ' { platform: "github", repositories: [$repo], onboarding: false, requireConfig: "optional", branchPrefix: $branch_prefix, enabledManagers: ["gomod", "npm"], dependencyDashboard: false, # Branches only: with no dashboard, no PR is ever opened. The workflow opens it after the tests. prCreation: "approval", # A PR closed after a merge or by hand must not stop a later update of the same package. recreateWhen: "always", rebaseWhen: "behind-base-branch", semanticCommits: "enabled", semanticCommitType: "chore", semanticCommitScope: "deps", minimumReleaseAge: $age, prConcurrentLimit: 0, prHourlyLimit: 0, branchConcurrentLimit: 0, # A Go major update changes the import path: rewrite the imports, so the build tests the real update. postUpdateOptions: ["gomodTidy", "gomodUpdateImportPaths"], statusCheckNames: { artifactError: null, configValidation: null, mergeConfidence: null, minimumReleaseAge: null }, packageRules: ( [ ({ matchManagers: ["gomod", "npm"], groupName: "dependencies", groupSlug: "batch" } + (if $majors == "batch" then {} else {matchUpdateTypes: ["minor", "patch", "pin", "pinDigest", "digest"]} end)), {matchManagers: ["gomod"], matchDepTypes: ["indirect"], enabled: true}, # The parent module chooses the major version of an indirect dependency. {matchManagers: ["gomod"], matchDepTypes: ["indirect"], allowedVersions: "<{{add major 1}}.0.0"}, {matchPackageNames: ["typescript"], allowedVersions: "<7"} ] # Rules that disable by update type are ignored, but a version cap works. + (if $majors == "ignore" then [{matchManagers: ["gomod", "npm"], allowedVersions: "<{{add major 1}}.0.0"}] else [] end) ) }' > "$CONFIG_FILE" jq . "$CONFIG_FILE" - name: Run Renovate uses: renovatebot/github-action@v46.3.6 with: configurationFile: ${{ runner.temp }}/renovate-autoupdate-config.json token: ${{ github.token }} renovate-version: ${{ inputs.renovate-version }} env: LOG_LEVEL: info - name: List the update branches id: list env: GH_TOKEN: ${{ github.token }} run: | prefix="${BATCH_BRANCH%%/*}/" all=$(gh api --paginate "repos/$GITHUB_REPOSITORY/branches" --jq '.[].name' | grep -F "$prefix" || true) # The batch first, then each major update. ordered=$({ printf '%s\n' "$all" | grep -Fx "$BATCH_BRANCH" || true; printf '%s\n' "$all" | grep -vFx "$BATCH_BRANCH" || true; } | sed '/^$/d') json=$(printf '%s\n' "$ordered" | jq -R . | jq -sc 'map(select(length > 0))') echo "branches=$json" >> "$GITHUB_OUTPUT" echo "Update branches: $json" test-and-merge: name: Test and merge ${{ matrix.branch }} needs: renovate if: needs.renovate.outputs.branches != '[]' 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: 60 permissions: contents: write issues: write pull-requests: write strategy: fail-fast: false # One at a time, so each branch is tested on top of the ones merged before it. max-parallel: 1 matrix: branch: ${{ fromJSON(needs.renovate.outputs.branches) }} env: BRANCH: ${{ matrix.branch }} GH_TOKEN: ${{ github.token }} steps: - name: Find the default branch id: default run: echo "branch=$(gh api "repos/$GITHUB_REPOSITORY" --jq .default_branch)" >> "$GITHUB_OUTPUT" # Not the triggering ref: a push or a manual run from another branch must still test the default branch. - name: Checkout the default branch uses: actions/checkout@v6 with: ref: ${{ steps.default.outputs.branch }} fetch-depth: 0 lfs: ${{ inputs.lfs }} - name: Merge the update branch locally id: merge run: | git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" git fetch origin "$BRANCH" echo "head=$(git rev-parse FETCH_HEAD)" >> "$GITHUB_OUTPUT" echo "subject=$(git log -1 --format=%s FETCH_HEAD)" >> "$GITHUB_OUTPUT" if git merge-base --is-ancestor FETCH_HEAD HEAD; then echo "::notice::$BRANCH is already in the default branch." echo "skip=true" >> "$GITHUB_OUTPUT" elif git merge --no-ff --no-commit FETCH_HEAD; then echo "skip=false" >> "$GITHUB_OUTPUT" else git merge --abort echo "::warning::$BRANCH conflicts with the default branch. Renovate rebases it on the next run." echo "skip=true" >> "$GITHUB_OUTPUT" fi - name: Run workflow prepare script if: steps.merge.outputs.skip != 'true' shell: bash run: | if [ -f "./workflow-prepare.sh" ]; then chmod +x ./workflow-prepare.sh ./workflow-prepare.sh fi - name: Detect project types id: detect if: steps.merge.outputs.skip != 'true' env: GO_DIR: ${{ inputs.go-working-directory }} NODE_DIR: ${{ inputs.node-working-directory }} run: | go=false node=false pm=npm if [ -f "$GO_DIR/go.mod" ]; then go=true; fi if [ -f "$NODE_DIR/package.json" ]; then node=true if [ -f "$NODE_DIR/pnpm-lock.yaml" ]; then pm=pnpm elif [ -f "$NODE_DIR/yarn.lock" ]; then pm=yarn fi fi if [ "$go" = false ] && [ "$node" = false ]; then echo "::error::Neither $GO_DIR/go.mod nor $NODE_DIR/package.json exists, so there is nothing to test." exit 1 fi { echo "go=$go" echo "node=$node" echo "pm=$pm" } >> "$GITHUB_OUTPUT" - name: Setup Go if: steps.merge.outputs.skip != 'true' && steps.detect.outputs.go == 'true' uses: actions/setup-go@v6 with: go-version: ${{ inputs.go-version }} check-latest: true cache: true cache-dependency-path: ${{ inputs.go-working-directory }}/go.sum - name: Build and test Go if: steps.merge.outputs.skip != 'true' && steps.detect.outputs.go == 'true' working-directory: ${{ inputs.go-working-directory }} run: | set -o pipefail { go mod verify go build ./... go test -race -cover ./... } 2>&1 | tee -a "$RUNNER_TEMP/test-output.log" - name: Setup Node.js if: steps.merge.outputs.skip != 'true' && steps.detect.outputs.node == 'true' uses: actions/setup-node@v6 with: node-version: ${{ inputs.node-version }} - name: Install Node dependencies if: steps.merge.outputs.skip != 'true' && steps.detect.outputs.node == 'true' working-directory: ${{ inputs.node-working-directory }} env: PM: ${{ steps.detect.outputs.pm }} run: | set -o pipefail { # The lockfile must match what Renovate wrote: install without updating it. case "$PM" in pnpm | yarn) command -v corepack >/dev/null || npm install --global corepack corepack enable "$PM" ;; esac case "$PM" in pnpm) pnpm install --frozen-lockfile ;; yarn) yarn install --immutable ;; npm) 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 if: steps.merge.outputs.skip != 'true' && steps.detect.outputs.node == 'true' working-directory: ${{ inputs.node-working-directory }} env: PM: ${{ steps.detect.outputs.pm }} SCRIPTS: ${{ inputs.node-scripts }} REQUIRE_TESTS: ${{ inputs.require-node-tests }} run: | set -o pipefail log="$RUNNER_TEMP/test-output.log" 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 "package.json has no test script, so the update was not tested." >>"$log" exit 1 fi IFS=',' read -r -a scripts <<<"$SCRIPTS" for script in "${scripts[@]}"; do script=$(echo "$script" | xargs) [ -n "$script" ] || continue if jq -e --arg s "$script" '.scripts[$s]' package.json >/dev/null; then echo "::group::$PM run $script" "$PM" run "$script" 2>&1 | tee -a "$log" echo "::endgroup::" else echo "::notice::No '$script' script, skipping." fi done - name: Mark the tests as passed if: steps.merge.outputs.skip != 'true' run: touch "$RUNNER_TEMP/tests-passed" - name: Open the PR and merge it if: steps.merge.outputs.skip != 'true' env: HEAD_SHA: ${{ steps.merge.outputs.head }} SUBJECT: ${{ steps.merge.outputs.subject }} ADMIN: ${{ inputs.admin-merge && 'true' || '' }} run: | default=$(gh api "repos/$GITHUB_REPOSITORY" --jq .default_branch) pr=$(gh pr list --repo "$GITHUB_REPOSITORY" --head "$BRANCH" --state open --json number --jq '.[0].number // empty') if [ -z "$pr" ]; then url=$(gh pr create --repo "$GITHUB_REPOSITORY" --head "$BRANCH" --base "$default" \ --title "$SUBJECT" \ --body "Automated dependency update. The tests passed on the result of merging this branch into $default.") pr=${url##*/} fi # GitHub needs a moment to work out whether the PR can merge. for _ in $(seq 1 12); do state=$(gh pr view "$pr" --repo "$GITHUB_REPOSITORY" --json mergeable --jq .mergeable) [ "$state" != UNKNOWN ] && break sleep 5 done if [ "$state" = CONFLICTING ]; then gh pr close "$pr" --repo "$GITHUB_REPOSITORY" \ --comment "The tests passed, but the branch conflicts with $default. Renovate rebases it on the next run." echo "::warning::$BRANCH conflicts with $default, so PR #$pr was closed. Renovate rebases it on the next run." echo "\`$BRANCH\` passed its tests but conflicts with \`$default\`. Renovate rebases it on the next run." >> "$GITHUB_STEP_SUMMARY" exit 0 fi # --match-head-commit refuses to merge anything newer than the commit that was tested. if ! gh pr merge "$pr" --repo "$GITHUB_REPOSITORY" --squash --delete-branch \ --match-head-commit "$HEAD_SHA" \ --subject "$SUBJECT" \ --body "Automated dependency update. All tests passed." \ ${ADMIN:+--admin}; then gh pr close "$pr" --repo "$GITHUB_REPOSITORY" --comment "The tests passed, but GitHub refused the merge. See the workflow run. The next run tries again." exit 1 fi echo "Merged #$pr ($BRANCH)." >> "$GITHUB_STEP_SUMMARY" - name: Report the 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: | if [ -e "$RUNNER_TEMP/tests-passed" ]; then echo "The tests passed for \`$BRANCH\`, but the merge failed. See the log above. The next run tries again." >> "$GITHUB_STEP_SUMMARY" exit 0 fi 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="" 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 "
Test output, last lines" 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 "
" } > "$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("$"; ""))] | @tsv') if [ "$remaining" -eq 0 ]; then gh issue close "$issue" --repo "$GITHUB_REPOSITORY" --comment "Every dependency update now passes its tests." fi release: name: Dispatch the release needs: [renovate, test-and-merge] if: always() && needs.renovate.outputs.branches != '[]' && inputs.release-workflow != '' 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: actions: write contents: read steps: - name: Dispatch when the default branch moved env: GH_TOKEN: ${{ github.token }} RELEASE_WORKFLOW: ${{ inputs.release-workflow }} BASE_SHA: ${{ needs.renovate.outputs.base-sha }} run: | # A push made with github.token does not trigger workflows, so dispatch the release explicitly. branch=$(gh api "repos/$GITHUB_REPOSITORY" --jq .default_branch) now=$(gh api "repos/$GITHUB_REPOSITORY/commits/$branch" --jq .sha) if [ "$now" = "$BASE_SHA" ]; then echo "Nothing was merged, so no release." exit 0 fi gh workflow run "$RELEASE_WORKFLOW" --repo "$GITHUB_REPOSITORY" --ref "$branch"