diff --git a/.github/workflows/renovate-autoupdate.yaml b/.github/workflows/renovate-autoupdate.yaml index 83793d0..1b5965f 100644 --- a/.github/workflows/renovate-autoupdate.yaml +++ b/.github/workflows/renovate-autoupdate.yaml @@ -1,7 +1,8 @@ name: Renovate Autoupdate -# Renovate batches every Go and npm/pnpm/yarn update into one PR, the tests run -# on that branch, and the PR merges only when they pass. Replaces go-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: @@ -38,10 +39,10 @@ on: type: boolean default: true major-updates: - description: "What to do with major updates: 'ignore' skips them, 'separate' opens one PR per major that never merges automatically, 'batch' puts them in the batch." + 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: "ignore" + default: "separate" minimum-release-age: description: "Skip releases younger than this, for example '2 days'" required: false @@ -52,21 +53,16 @@ on: required: false type: string default: "44" - commit-subject: - description: "Squash commit subject. Keep it free of release keywords such as 'major' or 'breaking', which semver-generator matches." + 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: "chore(deps): update dependencies" + 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 - release-workflow: - description: "Workflow file to dispatch after the 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: "" lfs: description: "Enable Git LFS checkout (for repos with large files)" required: false @@ -87,15 +83,24 @@ env: jobs: renovate: - name: Update dependencies + 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: 20 + timeout-minutes: 45 permissions: contents: write pull-requests: write outputs: - pr-number: ${{ steps.find-pr.outputs.pr-number }} + 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 }} @@ -116,13 +121,16 @@ jobs: 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, - recreateWhen: "always", + branchConcurrentLimit: 0, postUpdateOptions: ["gomodTidy"], statusCheckNames: { artifactError: null, @@ -130,17 +138,19 @@ jobs: 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) + 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" @@ -153,43 +163,65 @@ jobs: env: LOG_LEVEL: info - - name: Find the batch PR - id: find-pr + - name: List the update branches + id: list env: GH_TOKEN: ${{ github.token }} run: | - number=$(gh pr list --repo "$GITHUB_REPOSITORY" --head "$BATCH_BRANCH" --state open --json number --jq '.[0].number // empty') - echo "pr-number=$number" >> "$GITHUB_OUTPUT" - if [ -z "$number" ]; then - echo "No dependency updates to test." - else - echo "Batch PR: #$number" - fi + 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: - name: Test the update + test-and-merge: + name: Test and merge ${{ matrix.branch }} needs: renovate - if: needs.renovate.outputs.pr-number != '' + 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: 45 + timeout-minutes: 60 permissions: - contents: read + contents: write pull-requests: write - outputs: - head-sha: ${{ steps.checkout-sha.outputs.sha }} + 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 batch branch + - name: Checkout the default branch uses: actions/checkout@v6 with: - ref: ${{ env.BATCH_BRANCH }} - token: ${{ github.token }} + fetch-depth: 0 lfs: ${{ inputs.lfs }} - - name: Record the tested commit - id: checkout-sha - run: echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" + - 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 @@ -199,6 +231,7 @@ jobs: - 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 }} @@ -224,7 +257,7 @@ jobs: } >> "$GITHUB_OUTPUT" - name: Setup Go - if: steps.detect.outputs.go == 'true' + if: steps.merge.outputs.skip != 'true' && steps.detect.outputs.go == 'true' uses: actions/setup-go@v6 with: go-version: ${{ inputs.go-version }} @@ -233,7 +266,7 @@ jobs: cache-dependency-path: ${{ inputs.go-working-directory }}/go.sum - name: Build and test Go - if: steps.detect.outputs.go == 'true' + if: steps.merge.outputs.skip != 'true' && steps.detect.outputs.go == 'true' working-directory: ${{ inputs.go-working-directory }} run: | go mod verify @@ -241,13 +274,13 @@ jobs: go test -race -cover ./... - name: Setup Node.js - if: steps.detect.outputs.node == 'true' + 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.detect.outputs.node == 'true' + if: steps.merge.outputs.skip != 'true' && steps.detect.outputs.node == 'true' working-directory: ${{ inputs.node-working-directory }} env: PM: ${{ steps.detect.outputs.pm }} @@ -268,7 +301,7 @@ jobs: esac - name: Run Node scripts - if: steps.detect.outputs.node == 'true' + if: steps.merge.outputs.skip != 'true' && steps.detect.outputs.node == 'true' working-directory: ${{ inputs.node-working-directory }} env: PM: ${{ steps.detect.outputs.pm }} @@ -292,48 +325,61 @@ jobs: fi done - - name: Report the failure on the PR - if: failure() + - name: Open the PR and merge it + if: steps.merge.outputs.skip != 'true' env: - GH_TOKEN: ${{ github.token }} - PR: ${{ needs.renovate.outputs.pr-number }} - RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} - run: | - gh pr comment "$PR" --repo "$GITHUB_REPOSITORY" \ - --body "The dependency update failed its tests, so it was not merged. See $RUN_URL. Renovate retries on the next run." - - merge: - name: Merge the update - needs: [renovate, test] - if: needs.test.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: - actions: write - contents: write - pull-requests: write - steps: - - name: Squash-merge the PR - env: - # A push made with github.token does not trigger workflows, so the release is dispatched below. - GH_TOKEN: ${{ github.token }} - PR: ${{ needs.renovate.outputs.pr-number }} - HEAD_SHA: ${{ needs.test.outputs.head-sha }} - SUBJECT: ${{ inputs.commit-subject }} + 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 \ + 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: Dispatch the release workflow - if: inputs.release-workflow != '' + - 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" diff --git a/README.md b/README.md index 85c7496..dd40873 100644 --- a/README.md +++ b/README.md @@ -104,9 +104,10 @@ jobs: Daily dependency updates for Go and npm/pnpm/yarn projects, built on Renovate. It replaces `go-autoupdate.yaml`. -1. Renovate puts every minor and patch update (direct and indirect Go modules, npm, pnpm and yarn packages) in one PR on the branch `deps-autoupdate/batch`. Major updates are skipped unless `major-updates` says otherwise. -2. The workflow checks out that branch and runs the tests: `go build`, `go test -race -cover` for Go, and the `package.json` scripts for Node. -3. If everything passes, it squash-merges the PR. If a test fails, the PR stays open with a comment and Renovate retries on the next run. +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. +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. +4. If anything merged and `release-workflow` is set, it dispatches the release once. ```yaml name: Update dependencies @@ -129,7 +130,7 @@ jobs: release-workflow: release.yaml ``` -The workflow finds `go.mod` and `package.json` itself and tests what exists. A repo with both, such as a Go backend with a Vue frontend, gets both tested in one run. +The workflow finds `go.mod` and `package.json` itself and tests what exists. A repo with both, such as a Go backend with a Vue frontend, gets both tested for every branch. **Inputs:** | Input | Default | Description | @@ -140,15 +141,16 @@ The workflow finds `go.mod` and `package.json` itself and tests what exists. A r | `node-working-directory` | `.` | Directory that holds `package.json` | | `node-scripts` | `lint,typecheck,test,build` | Scripts to run in order. A missing script is skipped. | | `require-node-tests` | `true` | Fail when there is no `test` script, so an untested update never merges | -| `major-updates` | `ignore` | `ignore` skips major updates. `separate` opens one PR per major that never merges automatically. `batch` puts majors in the batch. | +| `major-updates` | `separate` | `separate`: each major gets its own branch, tested and merged on its own. `batch`: majors join the batch. `ignore`: skip majors. | | `minimum-release-age` | `2 days` | Skip releases younger than this | | `renovate-version` | `44` | Renovate version | -| `commit-subject` | `chore(deps): update dependencies` | Squash commit subject | -| `release-workflow` | | Workflow file to dispatch after the merge, for example `release.yaml`. It needs a `workflow_dispatch` trigger. Empty means no dispatch. | +| `release-workflow` | | Workflow file to dispatch after a merge, for example `release.yaml`. It needs a `workflow_dispatch` trigger. Empty means no dispatch. | | `admin-merge` | `false` | Merge with `--admin`. The workflow token cannot bypass branch protection. | | `lfs` | `false` | Git LFS checkout | | `runner` | | Runner label or runner group, as in the other workflows | +**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`. **Per-project rules.** Put a `renovate.json` in the calling repository. Renovate reads it and merges it with the defaults, so no extra input is needed: @@ -164,10 +166,10 @@ The defaults already cap `typescript` below 7, which breaks `vue-tsc`. **Token.** No secret is needed. Everything runs inside the calling repository with the workflow's own token, which needs `contents: write` and `pull-requests: write`. Two repository settings matter: -- Settings, Actions, General: tick "Allow GitHub Actions to create and approve pull requests". Without it Renovate cannot open the PR. -- A push made with the workflow token does not trigger other workflows, so a release workflow that runs on `push` will not start after the merge. Set `release-workflow` to dispatch it explicitly, and grant `actions: write`. +- Settings, Actions, General: tick "Allow GitHub Actions to create and approve pull requests". Without it the workflow cannot open the PR. +- A push made with the workflow token does not trigger other workflows, so a release workflow that runs on `push` will not start after a merge. Set `release-workflow` to dispatch it, and grant `actions: write`. -**Commit message.** The squash commit uses a fixed subject and body without the words `major`, `minor` or `breaking`, because `semver-generator` matches release keywords in commit messages. +**Commit message.** The squash commit subject is the Renovate commit message, for example `chore(deps): update dependencies`, and the body is fixed text. Neither contains `major` or `breaking`, which `semver-generator` matches in commit messages. ## Composite Actions