diff --git a/.github/workflows/renovate-autoupdate.yaml b/.github/workflows/renovate-autoupdate.yaml new file mode 100644 index 0000000..ec30ae8 --- /dev/null +++ b/.github/workflows/renovate-autoupdate.yaml @@ -0,0 +1,322 @@ +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. +# 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 + include-major: + description: "Put major updates in the batch. When false, each major update gets its own PR that is never merged automatically." + required: false + type: boolean + default: false + 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" + commit-subject: + description: "Squash commit subject. Keep it free of release keywords such as 'major' or 'breaking', which semver-generator matches." + required: false + type: string + default: "chore(deps): update dependencies" + admin-merge: + description: "Merge with --admin, which bypasses branch protection" + required: false + type: boolean + default: true + 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: Update dependencies + 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 + permissions: + contents: write + pull-requests: write + outputs: + pr-number: ${{ steps.find-pr.outputs.pr-number }} + steps: + - name: Write Renovate config + env: + MIN_AGE: ${{ inputs.minimum-release-age }} + INCLUDE_MAJOR: ${{ inputs.include-major }} + CONFIG_FILE: ${{ runner.temp }}/renovate-autoupdate-config.json + run: | + jq -n \ + --arg repo "$GITHUB_REPOSITORY" \ + --arg age "$MIN_AGE" \ + --arg branch_prefix "${BATCH_BRANCH%%/*}/" \ + --argjson include_major "$INCLUDE_MAJOR" \ + ' + { + platform: "github", + repositories: [$repo], + onboarding: false, + requireConfig: "optional", + branchPrefix: $branch_prefix, + enabledManagers: ["gomod", "npm"], + dependencyDashboard: false, + semanticCommits: "enabled", + semanticCommitType: "chore", + semanticCommitScope: "deps", + minimumReleaseAge: $age, + prConcurrentLimit: 0, + prHourlyLimit: 0, + recreateWhen: "always", + postUpdateOptions: ["gomodTidy"], + statusCheckNames: { + artifactError: null, + configValidation: null, + mergeConfidence: null, + minimumReleaseAge: null + }, + packageRules: [ + ({ + matchManagers: ["gomod", "npm"], + groupName: "dependencies", + groupSlug: "batch" + } + (if $include_major then {} else {matchUpdateTypes: ["minor", "patch", "pin", "pinDigest", "digest"]} end)), + {matchManagers: ["gomod"], matchDepTypes: ["indirect"], enabled: true}, + {matchPackageNames: ["typescript"], allowedVersions: "<7"} + ] + }' > "$CONFIG_FILE" + jq . "$CONFIG_FILE" + + - name: Run Renovate + uses: renovatebot/github-action@v46.3.6 + with: + configurationFile: ${{ runner.temp }}/renovate-autoupdate-config.json + token: ${{ secrets.RENOVATE_TOKEN || secrets.HOMEBREW_TAP_TOKEN || github.token }} + renovate-version: ${{ inputs.renovate-version }} + env: + LOG_LEVEL: info + + - name: Find the batch PR + id: find-pr + env: + GH_TOKEN: ${{ secrets.RENOVATE_TOKEN || secrets.HOMEBREW_TAP_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 + + test: + name: Test the update + needs: renovate + if: needs.renovate.outputs.pr-number != '' + 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: read + pull-requests: write + outputs: + head-sha: ${{ steps.checkout-sha.outputs.sha }} + steps: + - name: Checkout the batch branch + uses: actions/checkout@v6 + with: + ref: ${{ env.BATCH_BRANCH }} + token: ${{ secrets.RENOVATE_TOKEN || secrets.HOMEBREW_TAP_TOKEN || github.token }} + lfs: ${{ inputs.lfs }} + + - name: Record the tested commit + id: checkout-sha + run: echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" + + - name: Run workflow prepare script + shell: bash + run: | + if [ -f "./workflow-prepare.sh" ]; then + chmod +x ./workflow-prepare.sh + ./workflow-prepare.sh + fi + + - name: Detect project types + id: detect + 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.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.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.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' + 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.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: Report the failure on the PR + if: failure() + env: + GH_TOKEN: ${{ secrets.RENOVATE_TOKEN || secrets.HOMEBREW_TAP_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: + contents: write + pull-requests: write + steps: + - name: Squash-merge the PR + env: + # A PAT or App token makes the merge push trigger the release workflow. github.token does not. + GH_TOKEN: ${{ secrets.RENOVATE_TOKEN || secrets.HOMEBREW_TAP_TOKEN || github.token }} + PR: ${{ needs.renovate.outputs.pr-number }} + HEAD_SHA: ${{ needs.test.outputs.head-sha }} + SUBJECT: ${{ inputs.commit-subject }} + ADMIN: ${{ inputs.admin-merge && 'true' || '' }} + run: | + # --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} diff --git a/README.md b/README.md index fa030e9..6c58e7a 100644 --- a/README.md +++ b/README.md @@ -88,7 +88,7 @@ jobs: ### `go-autoupdate.yaml` -Automatic dependency updates. +Automatic dependency updates for Go. See `renovate-autoupdate.yaml` for the Renovate-based replacement that also covers npm, pnpm and yarn. ```yaml jobs: @@ -100,6 +100,70 @@ jobs: secrets: inherit ``` +### `renovate-autoupdate.yaml` + +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`. +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. + +```yaml +name: Update dependencies + +on: + workflow_dispatch: + schedule: + - cron: "0 3 * * *" + +permissions: + contents: write + pull-requests: write + +jobs: + update: + uses: lukaszraczylo/shared-actions/.github/workflows/renovate-autoupdate.yaml@main + with: + go-version: ">=1.24" + secrets: inherit +``` + +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. + +**Inputs:** +| Input | Default | Description | +|-------|---------|-------------| +| `go-version` | `>=1.24` | Go version | +| `go-working-directory` | `.` | Directory that holds `go.mod` | +| `node-version` | `22` | Node.js version | +| `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 | +| `include-major` | `false` | Put major updates in the batch. When `false`, each major gets its own PR that is never merged automatically. | +| `minimum-release-age` | `2 days` | Skip releases younger than this | +| `renovate-version` | `44` | Renovate version | +| `commit-subject` | `chore(deps): update dependencies` | Squash commit subject | +| `admin-merge` | `true` | Merge with `--admin` | +| `lfs` | `false` | Git LFS checkout | +| `runner` | | Runner label or runner group, as in the other workflows | + +**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: + +```json +{ + "ignoreDeps": ["some/package"], + "packageRules": [{ "matchPackageNames": ["vite"], "enabled": false }] +} +``` + +The defaults already cap `typescript` below 7, which breaks `vue-tsc`. + +**Token.** The workflow uses `RENOVATE_TOKEN`, then `HOMEBREW_TAP_TOKEN`, then `github.token`. With `github.token` the merge push does not trigger other workflows, so a release workflow on `push` will not run. Use a PAT or App token for repositories that release on push. + +**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. + ## Composite Actions ### `actions/go-test`