Author SHA1 Message Date
tozuo-home-cluster-eso[bot]GitHubtozuo-home-cluster-eso[bot] <287217661+tozuo-home-cluster-eso[bot]@users.noreply.github.com>
a8a86485a9 chore(deps): update renovatebot/github-action action to v46.3.11 (#13)
Co-authored-by: tozuo-home-cluster-eso[bot] <287217661+tozuo-home-cluster-eso[bot]@users.noreply.github.com>
2026-11-02 18:22:34 +00:00
tozuo-home-cluster-eso[bot]GitHubtozuo-home-cluster-eso[bot] <287217661+tozuo-home-cluster-eso[bot]@users.noreply.github.com>
42c623fc6c chore(deps): update renovatebot/github-action action to v46.3.10 (#12)
Co-authored-by: tozuo-home-cluster-eso[bot] <287217661+tozuo-home-cluster-eso[bot]@users.noreply.github.com>
2026-10-29 06:19:10 +00:00
tozuo-home-cluster-eso[bot]GitHubtozuo-home-cluster-eso[bot] <287217661+tozuo-home-cluster-eso[bot]@users.noreply.github.com>
3d5fe9d4f8 chore(deps): update renovatebot/github-action action to v46.3.9 (#11)
Co-authored-by: tozuo-home-cluster-eso[bot] <287217661+tozuo-home-cluster-eso[bot]@users.noreply.github.com>
2026-10-22 11:19:33 +00:00
tozuo-home-cluster-eso[bot]GitHubtozuo-home-cluster-eso[bot] <287217661+tozuo-home-cluster-eso[bot]@users.noreply.github.com>
e70a775c27 chore(deps): update renovatebot/github-action action to v46.3.8 (#10)
Co-authored-by: tozuo-home-cluster-eso[bot] <287217661+tozuo-home-cluster-eso[bot]@users.noreply.github.com>
2026-10-15 05:20:29 +00:00
lukaszraczyloandGitHub 87651c95dc feat(autoupdate): add build-tags input to renovate-autoupdate (#9)
The default Go build and test step has no way to pass build tags.
A repository that needs tags, such as fts5, fails its scheduled run.

The new build-tags input defaults to an empty string.
With tags set, the step must run go build and go test with -tags.
It also sets CGO_ENABLED=1, as go-pr.yaml does.
Without tags, the commands stay unchanged.
The go-test-command input still replaces the default commands.
2026-10-10 01:31:40 +01:00
tozuo-home-cluster-eso[bot]GitHubtozuo-home-cluster-eso[bot] <287217661+tozuo-home-cluster-eso[bot]@users.noreply.github.com>
38e5cb0968 chore(deps): update renovatebot/github-action action to v46.3.7 (#8)
Co-authored-by: tozuo-home-cluster-eso[bot] <287217661+tozuo-home-cluster-eso[bot]@users.noreply.github.com>
2026-10-08 05:20:34 +00:00
lukaszraczylo 31cb4a7f8b fix(autoupdate): follow go.mod for the Go version
A dependency update can raise the go directive past the input range, which
made the test step fail with GOTOOLCHAIN=local. Raise the version to the
merged go.mod and let the test step fetch a toolchain when one is needed.
2026-10-05 11:47:37 +01:00
lukaszraczylo 33fdaf6689 fix(autoupdate): skip a branch whose lockfile the default branch also changed
A lockfile changed on both sides merges as text and can come out broken,
so a tested merge differs from the merge that lands. Skip the branch and
let Renovate regenerate it on the next run.
2026-10-05 11:11:27 +01:00
lukaszraczylo a0032ad2f8 fix(release): pass the Homebrew tap token to the cgo release 2026-10-04 00:38:35 +01:00
lukaszraczylo b7bb0468b2 Merge branch 'main' into feat/node-registry-workspace 2026-10-03 23:18:26 +01:00
lukaszraczylo 459d448941 fix(autoupdate): give pnpm workspace lockfile updates the registry token 2026-10-03 23:08:39 +01:00
lukaszraczyloandGitHub c42cf115b8 chore(autoupdate): author the update merge as Lukasz Raczylo (#7)
The Renovate autoupdate workflow merged each update branch with the
github-actions bot identity. It now uses [email protected], the same
author as the cluster Renovate runner.
2026-10-03 23:05:07 +01:00
tozuo-home-cluster-eso[bot]GitHubtozuo-home-cluster-eso[bot] <287217661+tozuo-home-cluster-eso[bot]@users.noreply.github.com>
ffbec959bc chore(deps): update actions/checkout action to v7 (#4)
Co-authored-by: tozuo-home-cluster-eso[bot] <287217661+tozuo-home-cluster-eso[bot]@users.noreply.github.com>
2026-10-03 23:01:11 +01:00
tozuo-home-cluster-eso[bot]GitHubtozuo-home-cluster-eso[bot] <287217661+tozuo-home-cluster-eso[bot]@users.noreply.github.com>
5cb1998345 chore(deps): update actions/setup-go action to v7 (#5)
Co-authored-by: tozuo-home-cluster-eso[bot] <287217661+tozuo-home-cluster-eso[bot]@users.noreply.github.com>
2026-10-03 23:00:59 +01:00
tozuo-home-cluster-eso[bot]GitHubtozuo-home-cluster-eso[bot] <287217661+tozuo-home-cluster-eso[bot]@users.noreply.github.com>
852afeb5aa chore(deps): update actions/setup-node action to v7 (#6)
Co-authored-by: tozuo-home-cluster-eso[bot] <287217661+tozuo-home-cluster-eso[bot]@users.noreply.github.com>
2026-10-03 23:00:46 +01:00
lukaszraczylo b23191de71 fix(autoupdate): retry the PR merge on a transient GitHub error 2026-10-03 22:58:15 +01:00
lukaszraczyloandGitHub 8f20f32137 fix(renovate): do not post status checks (#3)
The GitHub App reads statuses but cannot write them. Renovate posts a
renovate/stability-days status on each branch for minimumReleaseAge. The
403 aborted the run as "repository changed" right after the first
branch was created. Turn the Renovate status checks off. minimumReleaseAge
still holds a release back.
2026-10-03 22:54:08 +01:00
lukaszraczylo 26ad486b78 Merge branch 'feat/pnpm-workspace' into feat/node-registry-workspace 2026-10-03 22:45:36 +01:00
lukaszraczylo 73983972db feat(node): support pnpm workspaces 2026-10-03 22:45:11 +01:00
lukaszraczylo caaad0efab feat(npm): support a private registry token 2026-10-03 22:44:30 +01:00
lukaszraczyloandGitHub cce3d04588 Merge pull request #2 from lukaszraczylo/feat/renovate-validation
feat: validate workflows and actions, and prepare Renovate bumps
2026-10-03 22:30:41 +01:00
lukaszraczylo b7c4914104 fix(renovate): leave tool-version inputs and the GoReleaser version alone 2026-10-03 22:28:58 +01:00
lukaszraczylo 86ba34bab5 fix(validate): run on every pull request so the required check always reports 2026-10-03 22:25:22 +01:00
lukaszraczylo 1aece66c7f feat: validate workflows and actions, and prepare Renovate bumps
scripts/check-actions.sh runs actionlint and checks that every external
uses: reference resolves. The validate workflow runs it, then runs the
go-test and node-build actions and the node-pr workflow against small
fixtures, so a bump of an action they use is exercised before it merges.

renovate.json bumps the actions this repository uses. Minor and patch
bumps merge themselves once validate is green. Major bumps wait for a
person, because every consumer calls these actions at @main. The same
script runs as a Renovate post-upgrade task and marks a failing branch so
no PR opens.
2026-10-03 22:22:22 +01:00
lukaszraczylo 2e49670452 feat(autoupdate): make go mod tidy optional
Encore adds imports at build time, so go mod tidy prunes go.sum entries
that the Encore build needs, and every batch failed with a missing go.sum
entry. Add go-mod-tidy, on by default, so an Encore app can turn it off.
2026-10-03 21:48:33 +01:00
lukaszraczylo 85900e7304 fix(autoupdate): give Renovate the pnpm version the tests use
Renovate wrote the lockfile with its own pnpm, in a form the pinned pnpm
rejected as an overrides mismatch, so every branch failed to install. Pass
pnpm-version to Renovate as a constraint.
2026-10-03 21:31:19 +01:00
lukaszraczylo 319b564d14 feat(autoupdate): let a repo set its own Go prepare and test commands
An Encore app cannot be tested with plain go build and go test. Add
go-prepare-command, which runs before the tests, and go-test-command, which
replaces the default Go build and test commands.
2026-10-03 21:30:17 +01:00
lukaszraczylo f854725a97 feat(node): let a caller pin the pnpm version
A repo with no packageManager field got the newest pnpm through Corepack.
Several repos pin pnpm in their own workflows instead. Add a pnpm-version
input to node-pr and renovate-autoupdate and activate it with corepack.
2026-10-03 21:05:05 +01:00
lukaszraczylo b06a5bb1b1 feat(autoupdate): dispatch several workflows after a merge
A repo can have more than one workflow that a push used to trigger, such
as a Docker build and a site deploy. Let release-workflow take a
comma-separated list and dispatch each one.
2026-10-03 21:04:20 +01:00
lukaszraczylo bdbd313c0d fix(autoupdate): let Renovate read issues in a private repo
Renovate queries the repository issues when it starts. The job token had
no issues permission, so a private repo returned FORBIDDEN and Renovate
stopped with platform-unknown-error. Public repos expose issues, which hid
the problem. Grant issues: read to the Renovate job.
2026-10-03 20:17:52 +01:00
lukaszraczylo 5e0790e131 feat(autoupdate): add a log-level input for Renovate
A Renovate failure at info level hides the cause. Let a caller raise the
level to debug for one run.
2026-10-03 20:13:26 +01:00
lukaszraczylo 8126310820 feat(node-pr): add pull request checks for Node projects
Add a reusable workflow that installs without changing the lockfile and
runs lint, typecheck, test and build, skipping scripts that do not exist.
It follows go-pr: a push to a branch with an open PR skips the duplicate
run, and stale runs are cancelled.
2026-10-03 18:53:55 +01:00
lukaszraczylo 114812c70e feat(autoupdate): update only the directories that are tested
Renovate updated every go.mod and package.json in a repo, while the tests
cover one module and one Node project. An update outside them would merge
untested. Restrict the managers to the configured directories, and add go
and node switches for a repo that has no Go code or no frontend to test.
2026-10-03 18:42:09 +01:00
lukaszraczylo c12df22051 fix(autoupdate): make Go major updates real
A Go major update changes the import path. Without rewriting imports the
PR only added the new module to go.mod, the tests passed, and Renovate
proposed it again. Rewrite the imports with gomodUpdateImportPaths so the
build tests the actual update, and cap indirect dependencies at their
current major, which the parent module decides.
2026-10-03 18:11:40 +01:00
lukaszraczylo 46d523860a fix(autoupdate): test the default branch and handle merge conflicts
The checkout used the triggering ref, so a run started from another
branch tested a stale tree and then hit conflicts. Check out the default
branch explicitly. When a passing PR conflicts, close it and skip, and
when the merge fails, close the PR and fail the job. Only a real test
failure goes to the issue.
2026-10-03 17:52:11 +01:00
lukaszraczylo 6f8fec0956 fix(autoupdate): recreate update branches whose PR was closed
Renovate skips a branch when a PR with the same title was closed, and
adds a comment to that PR. A PR closed after a merge or by hand must not
block the next update, so set recreateWhen to always.
2026-10-03 17:13:44 +01:00
lukaszraczylo 94d1fe3409 feat(autoupdate): report failing updates with their test output
A failing update has no PR, so its test output had nowhere to go. Capture
the output of the Go and Node steps. On failure, write it as a comment on
one sticky issue, one comment per branch, updated on every run. A final
job removes comments for branches that are gone and closes the issue when
no update fails. The step is best effort and also writes the run summary.
2026-10-03 17:05:17 +01:00
lukaszraczylo dc0437151c feat(autoupdate): open a PR only after the tests pass
Renovate now prepares branches and opens no PR. Each branch is merged
into the default branch locally and tested, one at a time. Only a branch
that passes gets a PR, which merges at once, so a failing update never
shows up as a PR. Majors default to one branch each. The release is
dispatched once, after anything merged.
2026-10-03 16:52:15 +01:00
lukaszraczylo aca729f8f7 fix(autoupdate): skip major updates by default
A repo with a backlog of majors got one PR per major on the first run.
Replace include-major with major-updates: ignore (default), separate or
batch. Ignore uses a version cap, because rules that disable by update
type have no effect.
2026-10-03 16:47:43 +01:00
19 changed files with 1237 additions and 170 deletions
+1 -1
View File
@@ -19,7 +19,7 @@ runs:
using: "composite"
steps:
- name: Setup Go
uses: actions/setup-go@v6
uses: actions/setup-go@v7
with:
go-version: ${{ inputs.go-version }}
+2
View File
@@ -79,6 +79,7 @@ runs:
env:
GITHUB_TOKEN: ${{ inputs.github-token }}
GORELEASER_KEY: ${{ inputs.goreleaser-key }}
HOMEBREW_TAP_TOKEN: ${{ inputs.homebrew-tap-token }}
CGO_ENABLED: ${{ inputs.cgo-enabled }}
- name: Run GoReleaser Pro (merge)
@@ -91,3 +92,4 @@ runs:
env:
GITHUB_TOKEN: ${{ inputs.github-token }}
GORELEASER_KEY: ${{ inputs.goreleaser-key }}
HOMEBREW_TAP_TOKEN: ${{ inputs.homebrew-tap-token }}
+1 -1
View File
@@ -18,7 +18,7 @@ runs:
using: "composite"
steps:
- name: Setup Node.js
uses: actions/setup-node@v6
uses: actions/setup-node@v7
with:
node-version: ${{ inputs.node-version }}
cache: "npm"
+2 -2
View File
@@ -37,7 +37,7 @@ jobs:
timeout-minutes: 30
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
fetch-depth: 0
lfs: ${{ inputs.lfs }}
@@ -51,7 +51,7 @@ jobs:
fi
- name: Setup Go
uses: actions/setup-go@v6
uses: actions/setup-go@v7
with:
go-version: ${{ inputs.go-version }}
check-latest: true
+12 -12
View File
@@ -78,7 +78,7 @@ jobs:
timeout-minutes: 15
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
fetch-depth: 0
lfs: ${{ inputs.lfs }}
@@ -92,7 +92,7 @@ jobs:
fi
- name: Setup Go
uses: actions/setup-go@v6
uses: actions/setup-go@v7
with:
go-version: ${{ inputs.go-version }}
cache: true
@@ -122,7 +122,7 @@ jobs:
timeout-minutes: 20
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
fetch-depth: 0
lfs: ${{ inputs.lfs }}
@@ -136,7 +136,7 @@ jobs:
fi
- name: Setup Go
uses: actions/setup-go@v6
uses: actions/setup-go@v7
with:
go-version: ${{ inputs.go-version }}
cache: true
@@ -185,7 +185,7 @@ jobs:
security-events: write
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
fetch-depth: 0
lfs: ${{ inputs.lfs }}
@@ -199,7 +199,7 @@ jobs:
fi
- name: Setup Go
uses: actions/setup-go@v6
uses: actions/setup-go@v7
with:
go-version: ${{ inputs.go-version }}
cache: true
@@ -226,7 +226,7 @@ jobs:
timeout-minutes: 10
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
fetch-depth: 0
lfs: ${{ inputs.lfs }}
@@ -240,7 +240,7 @@ jobs:
fi
- name: Setup Go
uses: actions/setup-go@v6
uses: actions/setup-go@v7
with:
go-version: ${{ inputs.go-version }}
cache: true
@@ -264,7 +264,7 @@ jobs:
security-events: write
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
lfs: ${{ inputs.lfs }}
@@ -277,7 +277,7 @@ jobs:
fi
- name: Setup Go
uses: actions/setup-go@v6
uses: actions/setup-go@v7
with:
go-version: ${{ inputs.go-version }}
cache: true
@@ -303,7 +303,7 @@ jobs:
coverage: ${{ steps.coverage.outputs.coverage }}
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
fetch-depth: 0
lfs: ${{ inputs.lfs }}
@@ -317,7 +317,7 @@ jobs:
fi
- name: Setup Go
uses: actions/setup-go@v6
uses: actions/setup-go@v7
with:
go-version: ${{ inputs.go-version }}
cache: true
+12 -9
View File
@@ -103,7 +103,7 @@ jobs:
steps:
- name: Checkout
if: inputs.preflight
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
lfs: false
@@ -119,6 +119,7 @@ jobs:
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GORELEASER_KEY: ${{ secrets.GORELEASER_PRO }}
HOMEBREW_TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }}
test:
name: Test
@@ -127,7 +128,7 @@ jobs:
timeout-minutes: 30
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
fetch-depth: 0
lfs: ${{ inputs.lfs }}
@@ -154,7 +155,7 @@ jobs:
timeout-minutes: 20
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
lfs: ${{ inputs.lfs }}
@@ -167,7 +168,7 @@ jobs:
fi
- name: Setup Node.js
uses: actions/setup-node@v6
uses: actions/setup-node@v7
with:
node-version: ${{ inputs.node-version }}
cache: "npm"
@@ -194,7 +195,7 @@ jobs:
timeout-minutes: 10
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
fetch-depth: 0
lfs: ${{ inputs.lfs }}
@@ -229,7 +230,7 @@ jobs:
timeout-minutes: 60
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
fetch-depth: 0
lfs: ${{ inputs.lfs }}
@@ -246,7 +247,7 @@ jobs:
fi
- name: Setup Go
uses: actions/setup-go@v6
uses: actions/setup-go@v7
with:
go-version: ${{ inputs.go-version }}
cache: true
@@ -285,6 +286,7 @@ jobs:
cgo-enabled: "1"
github-token: ${{ secrets.GITHUB_TOKEN }}
goreleaser-key: ${{ secrets.GORELEASER_PRO }}
homebrew-tap-token: ${{ secrets.HOMEBREW_TAP_TOKEN }}
- name: Upload artifacts
uses: actions/upload-artifact@v7
@@ -308,7 +310,7 @@ jobs:
timeout-minutes: 60
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
fetch-depth: 0
lfs: ${{ inputs.lfs }}
@@ -322,7 +324,7 @@ jobs:
fi
- name: Setup Go
uses: actions/setup-go@v6
uses: actions/setup-go@v7
with:
go-version: ${{ inputs.go-version }}
@@ -364,6 +366,7 @@ jobs:
mode: merge
github-token: ${{ secrets.GITHUB_TOKEN }}
goreleaser-key: ${{ secrets.GORELEASER_PRO }}
homebrew-tap-token: ${{ secrets.HOMEBREW_TAP_TOKEN }}
- name: Rolling release
if: inputs.rolling-release-tag != ''
+5 -5
View File
@@ -81,7 +81,7 @@ jobs:
steps:
- name: Checkout
if: inputs.preflight
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
lfs: false
@@ -104,7 +104,7 @@ jobs:
timeout-minutes: 30
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
lfs: ${{ inputs.lfs }}
@@ -131,7 +131,7 @@ jobs:
timeout-minutes: 10
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
fetch-depth: 0
lfs: ${{ inputs.lfs }}
@@ -162,7 +162,7 @@ jobs:
timeout-minutes: 60
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
fetch-depth: 0
lfs: ${{ inputs.lfs }}
@@ -184,7 +184,7 @@ jobs:
fi
- name: Setup Go
uses: actions/setup-go@v6
uses: actions/setup-go@v7
with:
go-version: ${{ inputs.go-version }}
+259
View File
@@ -0,0 +1,259 @@
name: Node Pull Request
# Checks for a Node, Vue or Astro project: install without touching the lockfile,
# then run lint, typecheck, test and build. A script that does not exist is skipped.
on:
workflow_call:
inputs:
node-version:
description: "Node.js version to use"
required: false
type: string
default: "22"
working-directory:
description: "Directory that holds package.json"
required: false
type: string
default: "."
workspace:
description: "The project is a pnpm workspace. working-directory then holds pnpm-workspace.yaml and pnpm-lock.yaml, and needs no package.json. Dependencies install once there, and scripts run in each of packages."
required: false
type: boolean
default: false
packages:
description: "With workspace, comma-separated workspace package directories, relative to working-directory."
required: false
type: string
default: ""
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"
pnpm-version:
description: "pnpm version to use, for a repo whose package.json has no packageManager field. Empty uses the packageManager field, or the newest pnpm."
required: false
type: string
default: ""
require-tests:
description: "Fail when package.json has no test script"
required: false
type: boolean
default: false
lfs:
description: "Enable Git LFS checkout (for repos with large files)"
required: false
type: boolean
default: false
npm-registry-scope:
description: "Scope served by a private npm registry, for example '@fortawesome'. Used only when npm-registry-url and the npm-registry-token secret are also set."
required: false
type: string
default: ""
npm-registry-url:
description: "URL of the private npm registry, for example 'https://npm.fontawesome.com/'. Used only when npm-registry-scope and the npm-registry-token secret are also set."
required: false
type: string
default: ""
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: ""
secrets:
npm-registry-token:
description: "Auth token for the private npm registry"
required: false
concurrency:
group: node-pr-${{ github.repository }}-${{ github.event_name }}-${{ github.head_ref || github.ref_name }}-${{ inputs.working-directory }}
cancel-in-progress: true
jobs:
pre:
name: Detect duplicate run
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: 5
permissions:
pull-requests: read
outputs:
skip: ${{ steps.dup.outputs.skip }}
steps:
# A push to a branch with an open PR also fires pull_request; run only the PR one.
- name: Skip push run when a PR is open
id: dup
uses: actions/github-script@v9
with:
script: |
let skip = false;
if (context.eventName === 'push') {
try {
const { data } = await github.rest.pulls.list({
owner: context.repo.owner,
repo: context.repo.repo,
head: `${context.repo.owner}:${context.ref.replace('refs/heads/', '')}`,
state: 'open',
});
skip = data.length > 0;
} catch (e) {
core.warning(`PR lookup failed, running checks: ${e.message}`);
}
}
core.setOutput('skip', String(skip));
if (skip) core.notice('Open PR found for this branch; checks run on the pull_request event.');
checks:
name: Checks (${{ inputs.working-directory }})
needs: pre
if: needs.pre.outputs.skip != 'true'
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: 30
permissions:
contents: read
steps:
- name: Checkout
uses: actions/checkout@v7
with:
lfs: ${{ inputs.lfs }}
- 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 the package manager
id: detect
working-directory: ${{ inputs.working-directory }}
env:
WORKSPACE: ${{ inputs.workspace }}
PACKAGES: ${{ inputs.packages }}
run: |
if [ "$WORKSPACE" = true ]; then
if [ ! -f pnpm-workspace.yaml ] || [ ! -f pnpm-lock.yaml ]; then
echo "::error::workspace needs pnpm-workspace.yaml and pnpm-lock.yaml in ${{ inputs.working-directory }}."
exit 1
fi
count=0
IFS=',' read -r -a packages <<<"$PACKAGES"
for package in "${packages[@]}"; do
package=$(echo "$package" | xargs)
[ -n "$package" ] || continue
if [ ! -f "$package/package.json" ]; then
echo "::error::packages lists $package, but ${{ inputs.working-directory }}/$package/package.json does not exist."
exit 1
fi
count=$((count + 1))
done
if [ "$count" -eq 0 ]; then
echo "::error::workspace needs packages: the packages to check."
exit 1
fi
echo "pm=pnpm" >> "$GITHUB_OUTPUT"
exit 0
fi
if [ ! -f package.json ]; then
echo "::error::No package.json in ${{ inputs.working-directory }}."
exit 1
fi
pm=npm
if [ -f pnpm-lock.yaml ]; then pm=pnpm
elif [ -f yarn.lock ]; then pm=yarn
fi
echo "pm=$pm" >> "$GITHUB_OUTPUT"
- name: Setup Node.js
uses: actions/setup-node@v7
with:
node-version: ${{ inputs.node-version }}
# The npmrc lives in RUNNER_TEMP, not the checkout, so no git command can pick the token up.
- name: Configure the private npm registry
env:
NPM_SCOPE: ${{ inputs.npm-registry-scope }}
NPM_URL: ${{ inputs.npm-registry-url }}
NPM_TOKEN: ${{ secrets.npm-registry-token }}
run: |
[ -n "$NPM_SCOPE" ] && [ -n "$NPM_URL" ] && [ -n "$NPM_TOKEN" ] || exit 0
echo "::add-mask::$NPM_TOKEN"
url="${NPM_URL%/}/"
npmrc="$RUNNER_TEMP/private-registry.npmrc"
(umask 077 && printf '%s:registry=%s\n//%s:_authToken=%s\n' "$NPM_SCOPE" "$url" "${url#*://}" "$NPM_TOKEN" > "$npmrc")
echo "NPM_CONFIG_USERCONFIG=$npmrc" >> "$GITHUB_ENV"
echo "Using a private npm registry for $NPM_SCOPE."
- name: Install dependencies
working-directory: ${{ inputs.working-directory }}
env:
PM: ${{ steps.detect.outputs.pm }}
PNPM_VERSION: ${{ inputs.pnpm-version }}
run: |
# Install without updating the lockfile, so a lockfile that does not match package.json fails.
case "$PM" in
pnpm | yarn)
command -v corepack >/dev/null || npm install --global corepack
corepack enable "$PM"
;;
esac
if [ "$PM" = pnpm ] && [ -n "$PNPM_VERSION" ]; then
corepack prepare "pnpm@$PNPM_VERSION" --activate
fi
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 scripts
working-directory: ${{ inputs.working-directory }}
env:
PM: ${{ steps.detect.outputs.pm }}
SCRIPTS: ${{ inputs.scripts }}
REQUIRE_TESTS: ${{ inputs.require-tests }}
WORKSPACE: ${{ inputs.workspace }}
PACKAGES: ${{ inputs.packages }}
run: |
# A single project is the one package ".". A workspace is each listed package.
packages=(.)
if [ "$WORKSPACE" = true ]; then
IFS=',' read -r -a packages <<<"$PACKAGES"
fi
if [ "$REQUIRE_TESTS" = true ]; then
for package in "${packages[@]}"; do
package=$(echo "$package" | xargs)
[ -n "$package" ] || continue
if ! jq -e '.scripts.test' "$package/package.json" >/dev/null; then
echo "::error::$package/package.json has no test script."
exit 1
fi
done
fi
ran=()
IFS=',' read -r -a scripts <<<"$SCRIPTS"
for script in "${scripts[@]}"; do
script=$(echo "$script" | xargs)
[ -n "$script" ] || continue
for package in "${packages[@]}"; do
package=$(echo "$package" | xargs)
[ -n "$package" ] || continue
where=""
[ "$package" = . ] || where=" ($package)"
if jq -e --arg s "$script" '.scripts[$s]' "$package/package.json" >/dev/null; then
echo "::group::$PM run $script$where"
(cd "$package" && "$PM" run "$script")
echo "::endgroup::"
ran+=("$script$where")
else
echo "::notice::No '$script' script$where, skipping."
fi
done
done
if [ "${#ran[@]}" -eq 0 ]; then
echo "::warning::None of the scripts ($SCRIPTS) exist in package.json, so nothing was checked."
fi
echo "Ran: ${ran[*]:-none} (${PM})" >> "$GITHUB_STEP_SUMMARY"
+512 -99
View File
@@ -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:
@@ -12,8 +13,13 @@ on:
required: false
type: string
default: ">=1.24"
go:
description: "Update and test the Go module. Turn off for a repo with no Go code."
required: false
type: boolean
default: true
go-working-directory:
description: "Directory that holds go.mod"
description: "Directory that holds go.mod. Only this module is updated and tested."
required: false
type: string
default: "."
@@ -22,8 +28,33 @@ on:
required: false
type: string
default: "22"
go-mod-tidy:
description: "Run go mod tidy after a Go update. Turn it off for an Encore app: Encore adds imports at build time, so tidy prunes go.sum entries the build needs."
required: false
type: boolean
default: true
go-prepare-command:
description: "Shell commands to run before the Go tests, for example installing a CLI. To put a tool on PATH, append its directory to $GITHUB_PATH."
required: false
type: string
default: ""
go-test-command:
description: "Replaces the default Go build and test commands, for example 'encore test ./...' for an Encore app. It runs in go-working-directory."
required: false
type: string
default: ""
build-tags:
description: "Build tags to pass to go build and go test (e.g., 'fts5'). The default commands then run with CGO_ENABLED=1. Ignored when go-test-command is set."
required: false
type: string
default: ""
node:
description: "Update and test the Node project. Turn off for a repo with no frontend to test."
required: false
type: boolean
default: true
node-working-directory:
description: "Directory that holds package.json"
description: "Directory that holds package.json. Only this project is updated and tested."
required: false
type: string
default: "."
@@ -32,16 +63,31 @@ on:
required: false
type: string
default: "lint,typecheck,test,build"
node-workspace:
description: "The Node project is a pnpm workspace. node-working-directory then holds pnpm-workspace.yaml and pnpm-lock.yaml, and needs no package.json. Dependencies install once there, and node-scripts run in each of node-packages."
required: false
type: boolean
default: false
node-packages:
description: "With node-workspace, comma-separated workspace package directories, relative to node-working-directory. Only these are updated and tested."
required: false
type: string
default: ""
pnpm-version:
description: "pnpm version to use, for a repo whose package.json has no packageManager field. Empty uses the packageManager field, or the newest pnpm."
required: false
type: string
default: ""
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."
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: boolean
default: false
type: string
default: "separate"
minimum-release-age:
description: "Skip releases younger than this, for example '2 days'"
required: false
@@ -52,31 +98,45 @@ 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. Give several as a comma-separated list. Each 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."
log-level:
description: "Renovate log level: info, debug or trace. Use debug to see why Renovate fails."
required: false
type: string
default: ""
default: "info"
lfs:
description: "Enable Git LFS checkout (for repos with large files)"
required: false
type: boolean
default: false
npm-registry-scope:
description: "Scope served by a private npm registry, for example '@fortawesome'. Used only when npm-registry-url and the npm-registry-token secret are also set."
required: false
type: string
default: ""
npm-registry-url:
description: "URL of the private npm registry, for example 'https://npm.fontawesome.com/'. Used only when npm-registry-scope and the npm-registry-token secret are also set."
required: false
type: string
default: ""
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: ""
secrets:
npm-registry-token:
description: "Auth token for the private npm registry"
required: false
concurrency:
group: renovate-autoupdate-${{ github.repository }}
@@ -84,110 +144,227 @@ concurrency:
env:
BATCH_BRANCH: deps-autoupdate/batch
ISSUE_TITLE: Dependency updates failing tests
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
# Renovate reads the repository's issues when it starts. A private repo refuses that without this.
issues: read
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 }}
INCLUDE_MAJOR: ${{ inputs.include-major }}
MAJOR_UPDATES: ${{ inputs.major-updates }}
USE_GO: ${{ inputs.go }}
USE_NODE: ${{ inputs.node }}
GO_DIR: ${{ inputs.go-working-directory }}
NODE_DIR: ${{ inputs.node-working-directory }}
NODE_WORKSPACE: ${{ inputs.node-workspace }}
NODE_PACKAGES: ${{ inputs.node-packages }}
PNPM_VERSION: ${{ inputs.pnpm-version }}
GO_MOD_TIDY: ${{ inputs.go-mod-tidy }}
NPM_SCOPE: ${{ inputs.npm-registry-scope }}
NPM_URL: ${{ inputs.npm-registry-url }}
NPM_TOKEN: ${{ secrets.npm-registry-token }}
CONFIG_FILE: ${{ runner.temp }}/renovate-autoupdate-config.json
run: |
# The token goes to jq through the environment, never argv, and the config print below hides it.
export NPMRC=""
if [ -n "$NPM_SCOPE" ] && [ -n "$NPM_URL" ] && [ -n "$NPM_TOKEN" ]; then
echo "::add-mask::$NPM_TOKEN"
url="${NPM_URL%/}/"
NPMRC=$(printf '%s:registry=%s\n//%s:_authToken=%s\n' "$NPM_SCOPE" "$url" "${url#*://}" "$NPM_TOKEN")
# Renovate writes its npmrc option into the package directory, but a pnpm workspace install reads
# only the root, so the lockfile update gets a 404. The Renovate container shares /tmp with the host.
RENOVATE_NPMRC_FILE="/tmp/renovate-npmrc-$GITHUB_RUN_ID"
(umask 022 && printf '%s\n' "$NPMRC" > "$RENOVATE_NPMRC_FILE")
echo "RENOVATE_NPMRC_FILE=$RENOVATE_NPMRC_FILE" >> "$GITHUB_ENV"
export RENOVATE_NPMRC_FILE
fi
jq -n \
--arg repo "$GITHUB_REPOSITORY" \
--arg age "$MIN_AGE" \
--arg branch_prefix "${BATCH_BRANCH%%/*}/" \
--argjson include_major "$INCLUDE_MAJOR" \
--arg majors "$MAJOR_UPDATES" \
--argjson use_go "$USE_GO" \
--argjson use_node "$USE_NODE" \
--arg go_dir "$GO_DIR" \
--arg node_dir "$NODE_DIR" \
--argjson node_workspace "$NODE_WORKSPACE" \
--arg node_packages "$NODE_PACKAGES" \
--arg pnpm_version "$PNPM_VERSION" \
--argjson go_tidy "$GO_MOD_TIDY" \
'
# A regex for files directly inside a directory, so only what the tests cover is updated.
def prefix($d): (if $d == "." or $d == "" then "" else ($d | ltrimstr("./") | rtrimstr("/")) + "/" end) | gsub("\\."; "\\.");
def pkgprefix($d; $p): prefix($d) + ($p | ltrimstr("./") | rtrimstr("/") | gsub("\\."; "\\.")) + "/";
# A workspace updates only its listed packages. The root lockfile is rewritten by pnpm with them.
def node_files:
if $node_workspace
then [$node_packages | split(",")[] | gsub("^\\s+|\\s+$"; "") | select(length > 0) | "/^" + pkgprefix($node_dir; .) + "package\\.json$/"]
else ["/^" + prefix($node_dir) + "package\\.json$/"]
end;
{
platform: "github",
repositories: [$repo],
onboarding: false,
requireConfig: "optional",
branchPrefix: $branch_prefix,
enabledManagers: ["gomod", "npm"],
enabledManagers: ((if $use_go then ["gomod"] else [] end) + (if $use_node then ["npm"] else [] end)),
# Renovate writes the lockfile with its own pnpm. Use the version the tests install with.
constraints: (if $pnpm_version != "" then {pnpm: $pnpm_version} else {} end),
gomod: {managerFilePatterns: ["/^" + prefix($go_dir) + "go\\.mod$/"]},
npm: {managerFilePatterns: (node_files + ["/^" + prefix($node_dir) + "pnpm-workspace\\.yaml$/"])},
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,
recreateWhen: "always",
postUpdateOptions: ["gomodTidy"],
branchConcurrentLimit: 0,
# A Go major update changes the import path: rewrite the imports, so the build tests the real update.
postUpdateOptions: ((if $go_tidy then ["gomodTidy"] else [] end) + ["gomodUpdateImportPaths"]),
statusCheckNames: {
artifactError: null,
configValidation: null,
mergeConfidence: null,
minimumReleaseAge: null
},
packageRules: [
packageRules: (
[
({
matchManagers: ["gomod", "npm"],
groupName: "dependencies",
groupSlug: "batch"
} + (if $include_major then {} else {matchUpdateTypes: ["minor", "patch", "pin", "pinDigest", "digest"]} end)),
} + (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"}
]
}' > "$CONFIG_FILE"
jq . "$CONFIG_FILE"
# 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)
)
}
| if $ENV.NPMRC != "" then .npmrc = $ENV.NPMRC | .customEnvVariables = {NPM_CONFIG_USERCONFIG: $ENV.RENOVATE_NPMRC_FILE} else . end' > "$CONFIG_FILE"
jq 'if has("npmrc") then .npmrc = "<redacted>" else . end' "$CONFIG_FILE"
- name: Run Renovate
uses: renovatebot/[email protected].6
uses: renovatebot/[email protected].11
with:
configurationFile: ${{ runner.temp }}/renovate-autoupdate-config.json
token: ${{ github.token }}
renovate-version: ${{ inputs.renovate-version }}
env:
LOG_LEVEL: info
LOG_LEVEL: ${{ inputs.log-level }}
- name: Find the batch PR
id: find-pr
- name: Remove the registry token file
if: always() && env.RENOVATE_NPMRC_FILE != ''
run: rm -f "$RENOVATE_NPMRC_FILE"
- 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
issues: 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
uses: actions/checkout@v6
- 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@v7
with:
ref: ${{ env.BATCH_BRANCH }}
token: ${{ github.token }}
ref: ${{ steps.default.outputs.branch }}
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 "Lukasz Raczylo"
git config user.email "[email protected]"
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
# A lockfile that both sides changed merges as text and can be broken. Renovate regenerates it on the next run.
base=$(git merge-base HEAD FETCH_HEAD)
stale=""
for lock in pnpm-lock.yaml package-lock.json yarn.lock; do
for f in $(git ls-tree -r --name-only FETCH_HEAD | grep -E "(^|/)${lock//./\\.}$"); do
if ! git diff --quiet "$base" HEAD -- "$f" && ! git diff --quiet "$base" FETCH_HEAD -- "$f"; then stale="$stale $f"; fi
done
done
if [ -n "$stale" ]; then
git merge --abort
echo "::warning::$BRANCH and the default branch both changed${stale}. Renovate rebases it on the next run."
echo "skip=true" >> "$GITHUB_OUTPUT"
else
echo "skip=false" >> "$GITHUB_OUTPUT"
fi
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
@@ -197,15 +374,42 @@ 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 }}
NODE_WORKSPACE: ${{ inputs.node-workspace }}
NODE_PACKAGES: ${{ inputs.node-packages }}
USE_GO: ${{ inputs.go }}
USE_NODE: ${{ inputs.node }}
run: |
go=false
node=false
pm=npm
if [ -f "$GO_DIR/go.mod" ]; then go=true; fi
if [ -f "$NODE_DIR/package.json" ]; then
if [ "$USE_GO" = true ] && [ -f "$GO_DIR/go.mod" ]; then go=true; fi
if [ "$USE_NODE" = true ] && [ "$NODE_WORKSPACE" = true ]; then
if [ ! -f "$NODE_DIR/pnpm-workspace.yaml" ] || [ ! -f "$NODE_DIR/pnpm-lock.yaml" ]; then
echo "::error::node-workspace needs pnpm-workspace.yaml and pnpm-lock.yaml in $NODE_DIR."
exit 1
fi
IFS=',' read -r -a packages <<<"$NODE_PACKAGES"
count=0
for package in "${packages[@]}"; do
package=$(echo "$package" | xargs)
[ -n "$package" ] || continue
if [ ! -f "$NODE_DIR/$package/package.json" ]; then
echo "::error::node-packages lists $package, but $NODE_DIR/$package/package.json does not exist."
exit 1
fi
count=$((count + 1))
done
if [ "$count" -eq 0 ]; then
echo "::error::node-workspace needs node-packages: the packages to update and test."
exit 1
fi
node=true
pm=pnpm
elif [ "$USE_NODE" = true ] && [ -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
@@ -221,35 +425,96 @@ jobs:
echo "pm=$pm"
} >> "$GITHUB_OUTPUT"
# The merged go.mod can need a newer Go than the input allows, because a dependency update raises its go directive.
- name: Pick the Go version
id: goversion
if: steps.merge.outputs.skip != 'true' && steps.detect.outputs.go == 'true'
env:
GO_VERSION_INPUT: ${{ inputs.go-version }}
GO_DIR: ${{ inputs.go-working-directory }}
run: |
want=$(awk '$1=="go"{print $2; exit}' "$GO_DIR/go.mod")
floor=$(printf '%s' "$GO_VERSION_INPUT" | sed -E 's/^[^0-9]*//' | cut -d. -f1,2)
need=$(printf '%s' "$want" | cut -d. -f1,2)
version=$GO_VERSION_INPUT
if [ -n "$need" ] && [ "$(printf '%s\n%s\n' "$need" "${floor:-0}" | sort -V | tail -n 1)" != "${floor:-0}" ]; then
version=">=$want"
fi
echo "version=$version" >> "$GITHUB_OUTPUT"
echo "::notice::Go $version (input '$GO_VERSION_INPUT', go.mod '$want')"
- name: Setup Go
if: steps.detect.outputs.go == 'true'
uses: actions/setup-go@v6
if: steps.merge.outputs.skip != 'true' && steps.detect.outputs.go == 'true'
uses: actions/setup-go@v7
with:
go-version: ${{ inputs.go-version }}
go-version: ${{ steps.goversion.outputs.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'
- name: Prepare Go
if: steps.merge.outputs.skip != 'true' && steps.detect.outputs.go == 'true' && inputs.go-prepare-command != ''
working-directory: ${{ inputs.go-working-directory }}
env:
GO_PREPARE_COMMAND: ${{ inputs.go-prepare-command }}
run: |
set -o pipefail
bash -c "$GO_PREPARE_COMMAND" 2>&1 | tee -a "$RUNNER_TEMP/test-output.log"
- name: Build and test Go
if: steps.merge.outputs.skip != 'true' && steps.detect.outputs.go == 'true'
working-directory: ${{ inputs.go-working-directory }}
env:
GO_TEST_COMMAND: ${{ inputs.go-test-command }}
TAGS: ${{ inputs.build-tags }}
GOTOOLCHAIN: auto
run: |
set -o pipefail
{
go mod verify
if [ -n "$GO_TEST_COMMAND" ]; then
bash -c "$GO_TEST_COMMAND"
elif [ -n "$TAGS" ]; then
export CGO_ENABLED=1
go build -tags "$TAGS" ./...
go test -tags "$TAGS" -race -cover ./...
else
go build ./...
go test -race -cover ./...
fi
} 2>&1 | tee -a "$RUNNER_TEMP/test-output.log"
- name: Setup Node.js
if: steps.detect.outputs.node == 'true'
uses: actions/setup-node@v6
if: steps.merge.outputs.skip != 'true' && steps.detect.outputs.node == 'true'
uses: actions/setup-node@v7
with:
node-version: ${{ inputs.node-version }}
# The npmrc lives in RUNNER_TEMP, not the checkout, so no git command can pick the token up.
- name: Configure the private npm registry
if: steps.merge.outputs.skip != 'true' && steps.detect.outputs.node == 'true'
env:
NPM_SCOPE: ${{ inputs.npm-registry-scope }}
NPM_URL: ${{ inputs.npm-registry-url }}
NPM_TOKEN: ${{ secrets.npm-registry-token }}
run: |
[ -n "$NPM_SCOPE" ] && [ -n "$NPM_URL" ] && [ -n "$NPM_TOKEN" ] || exit 0
echo "::add-mask::$NPM_TOKEN"
url="${NPM_URL%/}/"
npmrc="$RUNNER_TEMP/private-registry.npmrc"
(umask 077 && printf '%s:registry=%s\n//%s:_authToken=%s\n' "$NPM_SCOPE" "$url" "${url#*://}" "$NPM_TOKEN" > "$npmrc")
echo "NPM_CONFIG_USERCONFIG=$npmrc" >> "$GITHUB_ENV"
echo "Using a private npm registry for $NPM_SCOPE."
- 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 }}
PNPM_VERSION: ${{ inputs.pnpm-version }}
run: |
set -o pipefail
{
# The lockfile must match what Renovate wrote: install without updating it.
case "$PM" in
pnpm | yarn)
@@ -257,6 +522,9 @@ jobs:
corepack enable "$PM"
;;
esac
if [ "$PM" = pnpm ] && [ -n "$PNPM_VERSION" ]; then
corepack prepare "pnpm@$PNPM_VERSION" --activate
fi
case "$PM" in
pnpm) pnpm install --frozen-lockfile ;;
yarn) yarn install --immutable ;;
@@ -264,74 +532,219 @@ jobs:
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.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 }}
SCRIPTS: ${{ inputs.node-scripts }}
REQUIRE_TESTS: ${{ inputs.require-node-tests }}
NODE_WORKSPACE: ${{ inputs.node-workspace }}
NODE_PACKAGES: ${{ inputs.node-packages }}
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."
set -o pipefail
log="$RUNNER_TEMP/test-output.log"
# A single project is the one package ".". A workspace is each listed package.
packages=(.)
if [ "$NODE_WORKSPACE" = true ]; then
IFS=',' read -r -a packages <<<"$NODE_PACKAGES"
fi
if [ "$REQUIRE_TESTS" = true ]; then
for package in "${packages[@]}"; do
package=$(echo "$package" | xargs)
[ -n "$package" ] || continue
if ! jq -e '.scripts.test' "$package/package.json" >/dev/null; then
echo "::error::$package/package.json has no test script. Add one, or set require-node-tests to false."
echo "$package/package.json has no test script, so the update was not tested." >>"$log"
exit 1
fi
done
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"
for package in "${packages[@]}"; do
package=$(echo "$package" | xargs)
[ -n "$package" ] || continue
where=""
[ "$package" = . ] || where=" ($package)"
if jq -e --arg s "$script" '.scripts[$s]' "$package/package.json" >/dev/null; then
echo "::group::$PM run $script$where"
(cd "$package" && "$PM" run "$script") 2>&1 | tee -a "$log"
echo "::endgroup::"
else
echo "::notice::No '$script' script, skipping."
echo "::notice::No '$script' script$where, skipping."
fi
done
done
- name: Report the failure on the PR
if: failure()
- 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:
GH_TOKEN: ${{ github.token }}
PR: ${{ needs.renovate.outputs.pr-number }}
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.
# GitHub answers 5xx now and then; a failed call may still have merged, so check the state before retrying.
merged=false
for _ in 1 2 3; do
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
merged=true
break
fi
sleep 10
[ "$(gh pr view "$pr" --repo "$GITHUB_REPOSITORY" --json state --jq .state)" = MERGED ] && { merged=true; break; }
done
if [ "$merged" != true ]; 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: |
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."
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"
merge:
name: Merge the update
needs: [renovate, test]
if: needs.test.result == 'success'
log="$RUNNER_TEMP/test-output.log"
esc=$(printf '\033')
marker="<!-- renovate-autoupdate:$BRANCH -->"
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 "<details open><summary>Test output, last lines</summary>"
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 "</details>"
} > "$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("<!-- renovate-autoupdate:")) | [.id, (.body | split("\n")[0] | sub("^<!-- renovate-autoupdate:"; "") | sub(" -->$"; ""))] | @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: write
pull-requests: write
contents: read
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 }}
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}
- name: Dispatch the release workflow
if: inputs.release-workflow != ''
- 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)
gh workflow run "$RELEASE_WORKFLOW" --repo "$GITHUB_REPOSITORY" --ref "$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
IFS=',' read -r -a workflows <<<"$RELEASE_WORKFLOW"
for workflow in "${workflows[@]}"; do
workflow=$(echo "$workflow" | xargs)
[ -n "$workflow" ] || continue
gh workflow run "$workflow" --repo "$GITHUB_REPOSITORY" --ref "$branch"
done
+89
View File
@@ -0,0 +1,89 @@
name: Validate
# Checks that a change to the workflows or composite actions still works. Renovate
# opens a PR for each action bump; this workflow is the gate those PRs must pass.
# The aggregate job "validate" is the one required status check.
# No paths filter: "validate" is a required status check, and a required check
# that never runs blocks a PR that touches only other files.
on:
pull_request:
workflow_dispatch:
permissions:
contents: read
pull-requests: read
concurrency:
group: validate-${{ github.head_ref || github.ref_name }}
cancel-in-progress: true
jobs:
static:
name: Static checks
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Checkout
uses: actions/checkout@v7
# actionlint, then every external `uses:` reference must resolve.
- name: Check workflows and action references
run: scripts/check-actions.sh --ci
selftest-go:
name: Self-test go-test action
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Checkout
uses: actions/checkout@v7
# The local path runs the action as it is on this branch, not at @main.
- name: Run the go-test action on the fixture module
uses: ./.github/actions/go-test
with:
working-directory: testdata/selftest/go
selftest-node-build:
name: Self-test node-build action
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Checkout
uses: actions/checkout@v7
- name: Run the node-build action on the fixture project
uses: ./.github/actions/node-build
with:
build-script: cd testdata/selftest/node && npm ci && npm run build
cache-dependency-path: testdata/selftest/node/package-lock.json
selftest-node-pr:
name: Self-test node-pr workflow
uses: ./.github/workflows/node-pr.yaml
permissions:
contents: read
pull-requests: read
with:
working-directory: testdata/selftest/node
require-tests: true
validate:
name: validate
if: always()
needs: [static, selftest-go, selftest-node-build, selftest-node-pr]
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Require every check to pass
env:
RESULTS: ${{ join(needs.*.result, ' ') }}
run: |
echo "results: $RESULTS"
for r in $RESULTS; do
if [ "$r" != success ]; then
echo "::error::a validation job finished as '$r'"
exit 1
fi
done
+79 -13
View File
@@ -86,6 +86,52 @@ jobs:
secrets: inherit
```
### `node-pr.yaml`
Pull request checks for a Node, Vue or Astro project. It installs without changing the lockfile, then runs `lint`, `typecheck`, `test` and `build`. A script that does not exist is skipped. A push to a branch with an open PR skips the duplicate run, and stale runs on the same branch are cancelled. Call it once per project directory.
```yaml
name: Pull Request
on:
pull_request:
branches:
- main
push:
branches:
- "**"
- "!main"
permissions:
contents: read
pull-requests: read
jobs:
frontend:
uses: lukaszraczylo/shared-actions/.github/workflows/node-pr.yaml@main
with:
working-directory: web
```
**Inputs:**
| Input | Default | Description |
|-------|---------|-------------|
| `node-version` | `22` | Node.js version |
| `working-directory` | `.` | Directory that holds `package.json`. With `workspace`, the directory that holds `pnpm-workspace.yaml` and `pnpm-lock.yaml`. |
| `workspace` | `false` | pnpm workspace: install once in `working-directory`, then run `scripts` in each of `packages`. `working-directory` needs no `package.json`. |
| `packages` | | With `workspace`, comma-separated package directories relative to `working-directory`, for example `admin,public,packages/ui` |
| `scripts` | `lint,typecheck,test,build` | Scripts to run in order. A missing script is skipped. |
| `pnpm-version` | | pnpm version for a repo with no `packageManager` field. Empty uses the field, or the newest pnpm. |
| `require-tests` | `false` | Fail when there is no `test` script |
| `lfs` | `false` | Git LFS checkout |
| `npm-registry-scope` | | Scope served by a private npm registry, for example `@fortawesome`. |
| `npm-registry-url` | | URL of that registry, for example `https://npm.fontawesome.com/`. |
| `runner` | | Runner label or runner group, as in the other workflows |
pnpm is used when `pnpm-lock.yaml` exists, yarn for `yarn.lock`, otherwise npm. pnpm and yarn run through Corepack, so set `packageManager` in `package.json`. A workspace with no root `package.json` has no such field, so set `pnpm-version`. With `require-tests`, every listed package needs a `test` script.
**Private npm registry.** Set `npm-registry-scope`, `npm-registry-url` and the `npm-registry-token` secret together; with any one missing nothing changes. The token goes into an npmrc outside the checkout (`NPM_CONFIG_USERCONFIG`), is masked in logs, and works for npm, pnpm and yarn classic. Yarn 2+ ignores npmrc and needs its own `.yarnrc.yml`.
### `go-autoupdate.yaml`
Automatic dependency updates for Go. See `renovate-autoupdate.yaml` for the Renovate-based replacement that also covers npm, pnpm and yarn.
@@ -104,9 +150,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`.
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 opened. The test output goes into a comment on one sticky issue (see below), 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
@@ -119,6 +166,7 @@ on:
permissions:
actions: write # only to dispatch release-workflow
contents: write
issues: write # only to report failing updates
pull-requests: write
jobs:
@@ -129,27 +177,45 @@ 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 |
|-------|---------|-------------|
| `go-version` | `>=1.24` | Go version |
| `go-working-directory` | `.` | Directory that holds `go.mod` |
| `go` | `true` | Update and test the Go module. Turn off for a repo with no Go code. |
| `go-working-directory` | `.` | Directory that holds `go.mod`. Only this module is updated and tested. |
| `go-mod-tidy` | `true` | Run `go mod tidy` after a Go update. Turn it off for an Encore app, where tidy prunes `go.sum` entries the build needs. |
| `go-prepare-command` | | Shell commands to run before the Go tests, for example installing a CLI. Append a tool directory to `$GITHUB_PATH` to put it on `PATH`. |
| `go-test-command` | | Replaces the default `go build` and `go test`, for example `encore test ./...` for an Encore app. |
| `build-tags` | | Build tags for the default `go build` and `go test`, for example `fts5`. Also sets `CGO_ENABLED=1`. Ignored when `go-test-command` is set. |
| `node-version` | `22` | Node.js version |
| `node-working-directory` | `.` | Directory that holds `package.json` |
| `node` | `true` | Update and test the Node project. Turn off for a repo with no frontend to test. |
| `node-working-directory` | `.` | Directory that holds `package.json`. Only this project is updated and tested. With `node-workspace`, the directory that holds the workspace files. |
| `node-workspace` | `false` | pnpm workspace: `node-working-directory` holds `pnpm-workspace.yaml` and `pnpm-lock.yaml` and needs no `package.json`. Install once there, then run `node-scripts` in each of `node-packages`. |
| `node-packages` | | With `node-workspace`, comma-separated package directories relative to `node-working-directory`, for example `admin,public,packages/ui`. Only these are updated and tested. |
| `node-scripts` | `lint,typecheck,test,build` | Scripts to run in order. A missing script is skipped. |
| `pnpm-version` | | pnpm version for a repo with no `packageManager` field. Empty uses the field, or the newest pnpm. |
| `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. |
| `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`. Give several as a comma-separated list. Each 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 |
| `npm-registry-scope` | | Scope served by a private npm registry, for example `@fortawesome`. |
| `npm-registry-url` | | URL of that registry, for example `https://npm.fontawesome.com/`. |
| `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`.
**Private npm registry.** Set `npm-registry-scope`, `npm-registry-url` and the `npm-registry-token` secret together; with any one missing nothing changes. Renovate and the install step both use it. The token goes into an npmrc outside the checkout (`NPM_CONFIG_USERCONFIG`), is masked in logs, and works for npm, pnpm and yarn classic. Yarn 2+ ignores npmrc and needs its own `.yarnrc.yml`.
**Failing updates.** The last 50 KB of the test output goes into a comment on one issue titled "Dependency updates failing tests". Each failing branch has its own comment, updated on every run, and a header with the commit and the run link. When a branch passes and merges, its comment is removed. When no failing branch is left, the issue closes. The output is also in the run summary. Without `issues: write` the run still works, but the issue is skipped.
**Only what is tested is updated.** Renovate manages one Go module and one Node project: the ones in `go-working-directory` and `node-working-directory`. A second `package.json` (docs, e2e tests, another frontend) or a second `go.mod` is left alone, because nothing would test its updates. In a workspace the same rule applies to packages: only those in `node-packages` are updated, plus `pnpm-workspace.yaml` and the root `pnpm-lock.yaml`.
**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`. A workspace with no root `package.json` has no such field, so set `pnpm-version`. With `require-node-tests`, every listed package needs a `test` script.
**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 +230,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
+75
View File
@@ -0,0 +1,75 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"timezone": "Europe/London",
"enabledManagers": [
"github-actions"
],
"semanticCommits": "enabled",
"semanticCommitType": "chore",
"semanticCommitScope": "deps",
"dependencyDashboard": false,
"prConcurrentLimit": 0,
"prHourlyLimit": 0,
"minimumReleaseAge": "3 days",
"postUpgradeTasks": {
"commands": [
"scripts/check-actions.sh --renovate"
],
"fileFilters": [
".validation-failed"
],
"executionMode": "branch"
},
"packageRules": [
{
"description": "This repository's own composite actions are tracked at @main by design.",
"matchPackageNames": [
"lukaszraczylo/shared-actions"
],
"enabled": false
},
{
"description": "Tool versions in action inputs and defaults (Go, Node, GoReleaser) are policy, not pins. A bump would change what every consumer builds with.",
"matchDatasources": [
"golang-version",
"node-version"
],
"enabled": false
},
{
"description": "The GoReleaser version input is policy, not a pin.",
"matchDepNames": [
"goreleaser/goreleaser"
],
"enabled": false
},
{
"description": "Minor and patch bumps merge on their own once the validate check is green.",
"matchUpdateTypes": [
"minor",
"patch",
"digest",
"pin"
],
"automerge": true,
"automergeType": "pr",
"platformAutomerge": true
},
{
"description": "Every consumer calls these actions at @main, so a major bump waits for a human.",
"matchUpdateTypes": [
"major"
],
"automerge": false,
"addLabels": [
"major"
]
}
],
"statusCheckNames": {
"artifactError": null,
"configValidation": null,
"mergeConfidence": null,
"minimumReleaseAge": null
}
}
+115
View File
@@ -0,0 +1,115 @@
#!/usr/bin/env bash
# Static checks for the workflows and composite actions in this repository:
# 1. actionlint (schema, expressions, and shell steps through shellcheck when present)
# 2. every external `uses:` reference resolves to a real tag or branch
#
# Usage: scripts/check-actions.sh [--ci | --renovate]
# --ci exit 1 when a check fails (default)
# --renovate exit 0 and write .validation-failed when a check fails, so the
# Renovate runner deletes the branch before a PR can open
#
# Needs: bash, git, curl, tar, sha256sum or shasum. No other tools: actionlint is
# downloaded at a pinned version and checked against a pinned SHA-256.
set -u
mode=--ci
case "${1:-}" in
"" | --ci) mode=--ci ;;
--renovate) mode=--renovate ;;
*) echo "usage: $0 [--ci | --renovate]" >&2; exit 2 ;;
esac
ACTIONLINT_VERSION=1.7.12
ACTIONLINT_SHA256_AMD64=8aca8db96f1b94770f1b0d72b6dddcb1ebb8123cb3712530b08cc387b349a3d8
ACTIONLINT_SHA256_ARM64=325e971b6ba9bfa504672e29be93c24981eeb1c07576d730e9f7c8805afff0c6
marker=.validation-failed
cd "$(git rev-parse --show-toplevel)" || exit 2
fail=0
problem() {
echo "::error::$*" >&2
fail=1
}
sha256_of() {
if command -v sha256sum >/dev/null 2>&1; then sha256sum "$1" | cut -d' ' -f1; else shasum -a 256 "$1" | cut -d' ' -f1; fi
}
install_actionlint() {
local os arch want url tmp
case "$(uname -s)" in Linux) os=linux ;; Darwin) os=darwin ;; *) return 1 ;; esac
case "$(uname -m)" in
x86_64 | amd64) arch=amd64; want=$ACTIONLINT_SHA256_AMD64 ;;
aarch64 | arm64) arch=arm64; want=$ACTIONLINT_SHA256_ARM64 ;;
*) return 1 ;;
esac
# The pinned checksums are for the linux builds only.
[ "$os" = linux ] || return 1
tmp=$(mktemp -d)
url="https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/actionlint_${ACTIONLINT_VERSION}_${os}_${arch}.tar.gz"
curl -fsSL --retry 3 -o "$tmp/a.tgz" "$url" || return 1
[ "$(sha256_of "$tmp/a.tgz")" = "$want" ] || { echo "actionlint checksum mismatch" >&2; return 1; }
tar -xzf "$tmp/a.tgz" -C "$tmp" actionlint || return 1
echo "$tmp/actionlint"
}
run_actionlint() {
local bin
if command -v actionlint >/dev/null 2>&1; then
bin=$(command -v actionlint)
else
bin=$(install_actionlint) || { problem "cannot install actionlint ${ACTIONLINT_VERSION}"; return; }
fi
# -shellcheck= disables the shellcheck pass when the binary is missing.
local opts=()
command -v shellcheck >/dev/null 2>&1 || opts+=(-shellcheck=)
# Info and style findings from shellcheck (for example SC2086 on intentional word
# splitting) do not fail the gate. Warnings and errors do. Composite actions are
# not linted by actionlint 1.7; workflows are.
opts+=(-ignore 'shellcheck reported issue.+SC[0-9]+:(info|style):')
if ! "$bin" "${opts[@]}" -color .github/workflows/*.y*ml; then
problem "actionlint reported problems"
fi
}
# Resolves every `uses: owner/repo[/path]@ref` that points outside this repository.
check_refs() {
local files uses ref repo path checked=""
files=$(find .github -type f \( -name '*.yml' -o -name '*.yaml' \))
uses=$(grep -hoE '^[[:space:]]*(-[[:space:]]+)?uses:[[:space:]]*[^[:space:]#]+' $files | sed -E 's/^[[:space:]]*(-[[:space:]]+)?uses:[[:space:]]*//' | sort -u)
while IFS= read -r u; do
[ -n "$u" ] || continue
case "$u" in ./* | docker://*) continue ;; esac
repo=${u%%@*}
ref=${u#*@}
[ "$repo" != "$u" ] || { problem "no @ref in 'uses: $u'"; continue; }
# owner/repo/sub/path -> owner/repo
path=$(echo "$repo" | cut -d/ -f1,2)
# This repository's own composite actions are tracked at @main by design.
[ "$path" = lukaszraczylo/shared-actions ] && continue
case " $checked " in *" $path@$ref "*) continue ;; esac
checked="$checked $path@$ref"
if ! git ls-remote --exit-code "https://github.com/$path" "refs/tags/$ref" "refs/heads/$ref" >/dev/null 2>&1; then
# A full commit SHA is not listed by ls-remote; accept it when the repository answers at all.
if echo "$ref" | grep -qE '^[0-9a-f]{40}$' && git ls-remote --exit-code "https://github.com/$path" HEAD >/dev/null 2>&1; then
continue
fi
problem "'uses: $u' does not resolve: no tag or branch $ref in $path"
fi
done <<<"$uses"
}
rm -f "$marker"
run_actionlint
check_refs
if [ "$fail" -ne 0 ]; then
if [ "$mode" = --renovate ]; then
echo "validation failed; writing $marker" >&2
printf 'actions validation failed\n' >"$marker"
exit 0
fi
exit 1
fi
echo "actions validation passed"
+5
View File
@@ -0,0 +1,5 @@
// Package selftest is a fixture that the validate workflow runs the go-test action against.
package selftest
// Add returns the sum of a and b.
func Add(a, b int) int { return a + b }
+9
View File
@@ -0,0 +1,9 @@
package selftest
import "testing"
func TestAdd(t *testing.T) {
if got := Add(2, 3); got != 5 {
t.Fatalf("Add(2, 3) = %d, want 5", got)
}
}
+3
View File
@@ -0,0 +1,3 @@
module example.com/selftest
go 1.24
+12
View File
@@ -0,0 +1,12 @@
{
"name": "selftest",
"version": "0.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "selftest",
"version": "0.0.0"
}
}
}
+10
View File
@@ -0,0 +1,10 @@
{
"name": "selftest",
"version": "0.0.0",
"private": true,
"scripts": {
"lint": "node -e \"process.exit(0)\"",
"test": "node --test",
"build": "node -e \"console.log('built')\""
}
}
+6
View File
@@ -0,0 +1,6 @@
const test = require('node:test');
const assert = require('node:assert');
test('addition', () => {
assert.strictEqual(2 + 3, 5);
});