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 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", rebaseWhen: "behind-base-branch", semanticCommits: "enabled", semanticCommitType: "chore", semanticCommitScope: "deps", minimumReleaseAge: $age, prConcurrentLimit: 0, prHourlyLimit: 0, branchConcurrentLimit: 0, postUpdateOptions: ["gomodTidy"], 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}, {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 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: Checkout the default branch uses: actions/checkout@v6 with: 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: | go mod verify go build ./... go test -race -cover ./... - 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: | # 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 - 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: | 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." 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" echo "::endgroup::" else echo "::notice::No '$script' script, skipping." fi done - 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 # --match-head-commit refuses to merge anything newer than the commit that was tested. 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} echo "Merged #$pr ($BRANCH)." >> "$GITHUB_STEP_SUMMARY" - name: Report the failure if: failure() run: | echo "The tests failed for \`$BRANCH\`, so no PR was opened. Renovate retries on the next run." >> "$GITHUB_STEP_SUMMARY" 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"