diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a616e36..51b9c5a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,6 +14,10 @@ on: permissions: {} +env: + # renovate: datasource=github-releases depName=goreleaser/goreleaser + GORELEASER_VERSION: v1.25.1 + concurrency: group: ci-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: true @@ -55,6 +59,8 @@ jobs: permissions: contents: read env: + # renovate: datasource=go depName=github.com/google/go-licenses + GO_LICENSES_VERSION: v1.6.0 REPORT_FILE: gl-license-scanning-report.json steps: @@ -67,7 +73,7 @@ jobs: cache: false - name: License scanning run: | - go install github.com/google/go-licenses@v1.6.0 + scripts/install-go-tool.sh "github.com/google/go-licenses@${GO_LICENSES_VERSION}" go-licenses check . --disallowed_types=forbidden,restricted - name: Generate license report run: | @@ -99,7 +105,7 @@ jobs: # either 'goreleaser' (default) or 'goreleaser-pro' distribution: goreleaser # 'latest', 'nightly', or a semver - version: v1.25.1 + version: ${{ env.GORELEASER_VERSION }} args: check test: @@ -120,7 +126,9 @@ jobs: go-version-file: go.mod cache: false - name: Run tests - run: go test -v ./... + run: | + scripts/install-go-tool-test.sh + go test -v ./... security: name: GoReleaser artifacts - govulncheck, Trivy secrets/vulns, Grype @@ -129,6 +137,7 @@ jobs: permissions: contents: read env: + # renovate: datasource=go depName=golang.org/x/vuln GOVULNCHECK_VERSION: v1.6.0 steps: @@ -142,7 +151,7 @@ jobs: - name: Test govulncheck warning and error handling run: scripts/govulncheck-inform-test.sh - name: Install govulncheck - run: go install "golang.org/x/vuln/cmd/govulncheck@${GOVULNCHECK_VERSION}" + run: scripts/install-go-tool.sh "golang.org/x/vuln/cmd/govulncheck@${GOVULNCHECK_VERSION}" - name: Report reachable Go vulnerabilities in the source run: scripts/govulncheck-inform.sh ./... - name: Test that the symbol-table guard rejects a stripped binary @@ -151,7 +160,7 @@ jobs: uses: goreleaser/goreleaser-action@5daf1e915a5f0af01ddbcd89a43b8061ff4f1a89 # v7.2.2 with: distribution: goreleaser - version: v1.25.1 + version: ${{ env.GORELEASER_VERSION }} args: release --snapshot --clean --skip=publish - name: Verify and prepare all release artifacts run: scripts/prepare-artifact-scan-rootfs.sh @@ -211,7 +220,7 @@ jobs: # either 'goreleaser' (default) or 'goreleaser-pro' distribution: goreleaser # 'latest', 'nightly', or a semver - version: v1.25.1 + version: ${{ env.GORELEASER_VERSION }} args: release - name: Write latest version to file run: mkdir -p dist && echo "${TAG}" > dist/LATEST_VERSION diff --git a/.github/workflows/renovate.yml b/.github/workflows/renovate.yml index bedbd55..997c1c7 100644 --- a/.github/workflows/renovate.yml +++ b/.github/workflows/renovate.yml @@ -2,7 +2,7 @@ name: Renovate on: schedule: - - cron: "0 5 * * *" + - cron: "0 0 * * *" workflow_dispatch: permissions: {} diff --git a/README.md b/README.md index f482ae0..b1b9c48 100644 --- a/README.md +++ b/README.md @@ -458,9 +458,16 @@ This project uses GitHub Actions and GoReleaser for automated releases: ### Dependency updates Renovate runs daily and keeps compiler updates separate from library updates. +GitHub Actions patches, minors, and digest updates use a separate automerge PR. +Major action upgrades require manual review. +CI tools use a separate manual group; their major upgrades get individual PRs. Module patches and `golang.org/x/*` module minors share one library PR. +Manual AWS SDK and Kubernetes client updates have separate groups. +Other manual module updates get individual PRs. Compiler updates stay within the release line declared in `renovate.json`. +Module candidates that declare a newer required Go release line are filtered out. CI rejects a module update that selects a compiler from another release line. +Manual groups rebase only when conflicting; automatic groups stay current with main. The shared approval action approves verified Renovate commits with native automerge enabled. GitHub requires the existing CI checks and an approving review before merging. Compiler patches and the library group use native automerge after artifact validation and App approval. @@ -491,7 +498,7 @@ go test ./... Test the Renovate rules against an installed Renovate distribution: ```bash -mise exec -- node scripts/renovate-config-test.cjs /path/to/renovate/dist +mise exec -- node scripts/renovate-config-test.cjs /path/to/renovate/dist /path/to/renovate-config ``` ### Linting diff --git a/renovate.json b/renovate.json index ffd2080..f9389f4 100644 --- a/renovate.json +++ b/renovate.json @@ -9,10 +9,85 @@ "rebaseWhen": "behind-base-branch", "packageRules": [ { - "description": "Track direct and indirect Go module updates", + "description": "Review major upgrades separately without rebasing for unrelated changes", + "matchUpdateTypes": ["major"], + "groupName": null, + "groupSlug": null, + "automerge": false, + "rebaseWhen": "conflicted" + }, + { + "description": "Automerge compatible action updates after required CI and App approval", + "matchManagers": ["github-actions"], + "matchDepTypes": ["action"], + "matchUpdateTypes": ["patch", "minor", "digest", "pin", "pinDigest"], + "groupName": "GitHub Actions", + "groupSlug": "github-actions", + "automerge": true, + "automergeType": "pr", + "platformAutomerge": true + }, + { + "description": "Review CI tool updates separately from action references", + "matchManagers": ["github-actions", "custom.regex"], + "matchPackageNames": [ + "golangci/golangci-lint", + "goreleaser/goreleaser", + "github.com/google/go-licenses", + "golang.org/x/vuln" + ], + "matchUpdateTypes": ["patch", "minor", "digest", "pin", "pinDigest"], + "groupName": "CI tools", + "groupSlug": "ci-tools", + "automerge": false, + "rebaseWhen": "conflicted" + }, + { + "description": "Track Go modules compatible with the approved compiler release line", + "matchManagers": ["gomod"], + "matchDepTypes": ["require", "indirect", "replace"], + "enabled": true, + "constraintsFiltering": "strict", + "constraints": { + "%goMod": ">=1.0.0 <1.27.0" + }, + "constraintsVersioning": { + "%goMod": "semver-coerced" + } + }, + { + "description": "Review other module minors individually without rebasing for unrelated changes", "matchManagers": ["gomod"], "matchDepTypes": ["require", "indirect", "replace"], - "enabled": true + "matchUpdateTypes": ["minor"], + "groupName": null, + "groupSlug": null, + "automerge": false, + "rebaseWhen": "conflicted" + }, + { + "description": "Review coupled AWS SDK updates together", + "matchManagers": ["gomod"], + "matchDepTypes": ["require", "indirect", "replace"], + "matchPackageNames": ["github.com/aws/aws-sdk-go-v2{,/**}", "github.com/aws/smithy-go"], + "matchUpdateTypes": ["minor", "major"], + "groupName": "AWS Go SDK", + "groupSlug": "go-aws-sdk", + "group": { + "branchTopic": "go-aws-sdk" + } + }, + { + "description": "Review coupled Kubernetes clients and schema migrations together", + "matchManagers": ["gomod"], + "matchDepTypes": ["require", "indirect", "replace"], + "matchPackageNames": ["k8s.io/**", "sigs.k8s.io/**"], + "matchUpdateTypes": ["minor", "major"], + "groupName": "Kubernetes Go clients", + "groupSlug": "go-kubernetes", + "group": { + "branchTopic": "go-kubernetes" + } }, { "description": "Keep automerged patches available alongside manual module minors", @@ -64,7 +139,8 @@ "groupSlug": "go-libraries", "automerge": true, "automergeType": "pr", - "platformAutomerge": true + "platformAutomerge": true, + "rebaseWhen": "behind-base-branch" } ] } diff --git a/scripts/install-go-tool-test.sh b/scripts/install-go-tool-test.sh new file mode 100755 index 0000000..c5f495f --- /dev/null +++ b/scripts/install-go-tool-test.sh @@ -0,0 +1,48 @@ +#!/usr/bin/env bash + +set -euo pipefail + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +test_dir="$(mktemp -d)" +trap 'rm -rf "${test_dir}"' EXIT +export test_dir + +go() { + printf '%s\0' "$@" > "${test_dir}/args" + cmp "${test_dir}/expected-args" "${test_dir}/args" || return 99 + local attempt + attempt="$(cat "${test_dir}/attempts")" + attempt=$((attempt + 1)) + echo "${attempt}" > "${test_dir}/attempts" + if [[ "${attempt}" -le "${failures}" ]]; then + return "$((40 + attempt))" + fi +} + +sleep() { + [[ "$#" -eq 1 && "$1" == 2 ]] || return 99 + echo "$1" >> "${test_dir}/sleeps" +} +export -f go sleep + +args=(-tags "tag with spaces" "" "example.com/tool*@\$version") +printf '%s\0' install "${args[@]}" > "${test_dir}/expected-args" + +for failures in 0 1 2 3; do + export failures + echo 0 > "${test_dir}/attempts" + : > "${test_dir}/sleeps" + actual=0 + "${script_dir}/install-go-tool.sh" "${args[@]}" || actual=$? + expected=0 + attempts=$((failures + 1)) + if [[ "${failures}" -eq 3 ]]; then + expected=43 + attempts=3 + fi + [[ "${actual}" -eq "${expected}" ]] + [[ "$(cat "${test_dir}/attempts")" -eq "${attempts}" ]] + [[ "$(wc -l < "${test_dir}/sleeps")" -eq $((attempts - 1)) ]] +done + +echo "PASS: bounded retries, final failure status, delays, and arguments are preserved" diff --git a/scripts/install-go-tool.sh b/scripts/install-go-tool.sh new file mode 100755 index 0000000..3b3d0b5 --- /dev/null +++ b/scripts/install-go-tool.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash + +set -euo pipefail + +for attempt in 1 2 3; do + if go install "$@"; then + exit 0 + else + status=$? + fi + if [[ "${attempt}" -eq 3 ]]; then + exit "${status}" + fi + echo "Go tool installation failed (attempt ${attempt}/3); retrying in 2 seconds" >&2 + sleep 2 +done diff --git a/scripts/renovate-config-test.cjs b/scripts/renovate-config-test.cjs index 701e61f..1428ff7 100644 --- a/scripts/renovate-config-test.cjs +++ b/scripts/renovate-config-test.cjs @@ -5,22 +5,39 @@ const path = require('node:path'); const renovate = path.resolve(process.argv[2]); const { getConfig } = require(path.join(renovate, 'config/defaults.js')); const { mergeChildConfig } = require(path.join(renovate, 'config/utils.js')); +const { resolveConfigPresets } = require(path.join(renovate, 'config/presets/index.js')); const { applyPackageRules } = require(path.join(renovate, 'util/package-rules/index.js')); const { getDatasourceFor } = require(path.join(renovate, 'modules/datasource/common.js')); const { lookupUpdates } = require(path.join(renovate, 'workers/repository/process/lookup/index.js')); const { flattenUpdates } = require(path.join(renovate, 'workers/repository/updates/flatten.js')); +const actions = require(path.join(renovate, 'modules/manager/github-actions/index.js')); +const regex = require(path.join(renovate, 'modules/manager/custom/regex/index.js')); require(path.join(renovate, 'config/global.js')).GlobalConfig.set({ platform: 'github' }); async function main() { const repo = path.resolve(__dirname, '..'); - const config = mergeChildConfig(getConfig(), { + const { config: recommended } = await resolveConfigPresets({ extends: ['config:recommended'] }); + assert(recommended.packageRules?.length, 'config:recommended must include package rules'); + let config = mergeChildConfig(getConfig(), recommended); + assert(process.argv[3], 'Pass the shared renovate-config checkout as the third argument'); + for (const preset of ['default', 'go']) { + const shared = JSON.parse(fs.readFileSync(path.join(process.argv[3], `${preset}.json`))); + config = mergeChildConfig(config, { ...shared, extends: [] }); + } + config = mergeChildConfig(config, { ...JSON.parse(fs.readFileSync(path.join(repo, 'renovate.json'))), extends: [], semanticCommits: 'disabled', }); getDatasourceFor('go').getReleases = async () => ({ - releases: ['v1.0.0', 'v1.0.1', 'v1.1.0'].map(version => ({ + releases: [ + ['v1.0.0', '1.18.0'], + ['v1.0.1', '1.25.0'], + ['v1.1.0', '1.26.7'], + ['v1.2.0', '1.27.0'], + ].map(([version, go]) => ({ version, + constraints: { '%goMod': [go] }, releaseTimestamp: '2020-01-01T00:00:00Z', })), }); @@ -46,7 +63,9 @@ async function main() { const minor = branches.find(branch => branch.updateType === 'minor'); assert.equal(patch.branchName, 'renovate/go-libraries'); assert.equal(patch.automerge, true); + assert.equal(patch.rebaseWhen, 'behind-base-branch'); assert.equal(minor.automerge, false); + assert.equal(minor.rebaseWhen, 'conflicted'); assert.notEqual(patch.branchName, minor.branchName); const libraries = await flattenUpdates(config, { gomod: [{ @@ -68,7 +87,116 @@ async function main() { ); assert.equal(libraries.filter(branch => branch.automerge).length, 2); } + const actionBranches = await flattenUpdates(config, { + 'github-actions': [{ + packageFile: '.github/workflows/ci.yml', + deps: [{ + depName: 'actions/checkout', + packageName: 'actions/checkout', + depType: 'action', + datasource: 'github-tags', + currentValue: 'v7.0.0', + updates: [ + { updateType: 'patch', newValue: 'v7.0.1', newVersion: 'v7.0.1' }, + { updateType: 'minor', newValue: 'v7.1.0', newVersion: 'v7.1.0' }, + { updateType: 'digest', newValue: 'v7.0.0', newDigest: 'a'.repeat(40) }, + { updateType: 'pin', newValue: 'v7.0.0', newVersion: 'v7.0.0' }, + { updateType: 'pinDigest', newValue: 'v7.0.0', newDigest: 'a'.repeat(40) }, + { updateType: 'major', newValue: 'v8.0.0', newVersion: 'v8.0.0', newMajor: 8 }, + ], + }, { + depName: 'actions/setup-go', packageName: 'actions/setup-go', + depType: 'action', datasource: 'github-tags', currentValue: 'v6.0.0', + updates: [{ updateType: 'major', newValue: 'v7.0.0', newVersion: 'v7.0.0', newMajor: 7 }], + }], + }], + gomod: [{ + packageFile: 'go.mod', + deps: [{ + depName: 'example.com/library', + packageName: 'example.com/library', + depType: 'require', + datasource: 'go', + currentValue: 'v1.0.0', + updates: [{ updateType: 'patch', newValue: 'v1.0.1', newVersion: 'v1.0.1' }], + }], + }], + }); + for (const action of actionBranches.filter(branch => branch.manager === 'github-actions')) { + assert.equal(action.automerge, action.updateType !== 'major'); + assert.equal(action.branchName, action.updateType === 'major' + ? `renovate/${action.depName.replace('/', '-')}-${action.newMajor}.x` : 'renovate/github-actions'); + assert.equal(action.rebaseWhen, action.updateType === 'major' + ? 'conflicted' : 'behind-base-branch'); + if (action.automerge) { + assert.equal(action.platformAutomerge, true); + assert.equal(action.automergeType, 'pr'); + } + } + assert.equal(actionBranches.filter(branch => branch.branchName === 'renovate/github-actions').length, 5); + assert.equal(actionBranches.find(branch => branch.manager === 'gomod').branchName, 'renovate/go-libraries'); + for (const depType of ['github-runner', 'docker', 'uses-with']) { + const policy = await applyPackageRules({ + ...config, manager: 'github-actions', depType, updateType: 'patch', + }); + assert.equal(policy.automerge, false); + } + const modules = [ + ['github.com/aws/aws-sdk-go-v2', 'minor', 'go-aws-sdk'], + ['github.com/aws/aws-sdk-go-v2/config', 'minor', 'go-aws-sdk'], + ['github.com/aws/smithy-go', 'major', 'go-aws-sdk'], + ['k8s.io/api', 'minor', 'go-kubernetes'], + ['k8s.io/apimachinery', 'minor', 'go-kubernetes'], + ['k8s.io/client-go', 'minor', 'go-kubernetes'], + ['sigs.k8s.io/structured-merge-diff/v6', 'major', 'go-kubernetes'], + ['github.com/elastic/go-elasticsearch/v8', 'major', null], + ['github.com/stretchr/testify', 'minor', null], + ]; + const manual = await flattenUpdates(config, { + gomod: [{ + packageFile: 'go.mod', + deps: modules.map(([name, type]) => ({ + depName: name, packageName: name, depType: 'require', datasource: 'go', + currentValue: 'v1.0.0', + updates: [{ + updateType: type, newValue: type === 'major' ? 'v2.0.0' : 'v1.1.0', + newVersion: type === 'major' ? 'v2.0.0' : 'v1.1.0', newMajor: type === 'major' ? 2 : 1, + }], + })), + }], + }); + for (const branch of manual) { + const group = modules.find(([name]) => name === branch.depName)[2]; + assert.equal(branch.automerge, false); + assert.equal(branch.rebaseWhen, 'conflicted'); + if (group) assert.equal(branch.branchName, `renovate/${group}`); + else assert.equal(branch.groupName, null); + } + const workflow = fs.readFileSync(path.join(repo, '.github/workflows/ci.yml'), 'utf8'); + const extracted = [ + ...actions.extractPackageFile(workflow, '.github/workflows/ci.yml', config).deps, + ...config.customManagers.flatMap(manager => + regex.extractPackageFile(workflow, '.github/workflows/ci.yml', manager)?.deps ?? []), + ]; + for (const name of [ + 'golangci/golangci-lint', 'goreleaser/goreleaser', + 'github.com/google/go-licenses', 'golang.org/x/vuln', + ]) { + const dep = extracted.find(dep => (dep.packageName ?? dep.depName) === name); + assert(dep, `Missing CI tool extraction: ${name}`); + const manager = name === 'golangci/golangci-lint' ? 'github-actions' : 'custom.regex'; + for (const type of ['patch', 'minor', 'major']) { + const policy = await applyPackageRules({ + ...config, ...dep, packageName: name, manager, updateType: type, + }); + assert.equal(policy.automerge, false, name); + assert.equal(policy.rebaseWhen, 'conflicted', name); + assert.equal(policy.groupName, type === 'major' ? null : 'CI tools', name); + } + } console.log('PASS: available module patches automerge independently of manual minors'); + console.log('PASS: compatible GitHub Actions updates automerge separately; major actions stay manual'); + console.log('PASS: unsupported Go versions are filtered; manual families and CI tools stay separate'); } main().catch(error => {