Compare commits

...
Author SHA1 Message Date
lukaszraczylo 97b4c0ec7e fix(autoupdate): run the Renovate workflow with the workflow token only
The workflow runs inside the calling repository, so its own token is
enough and no PAT is needed. Drop the PAT fallbacks and make admin merge
opt-in, because the workflow token cannot bypass branch protection.
A merge made with that token does not trigger push workflows, so add a
release-workflow input that dispatches the release after the merge.
2026-10-03 16:07:46 +01:00
lukaszraczylo 413f9c3064 feat(autoupdate): add a Renovate workflow for Go and Node projects
Renovate batches every minor and patch update in one PR. The workflow
tests that branch with go build and go test for Go and with the
package.json scripts for npm, pnpm and yarn. It squash-merges the PR only
when every test passes. Major updates get their own PR and never merge by
themselves. A renovate.json in the calling repo adds project rules.
2026-10-03 16:01:34 +01:00
lukaszraczylo fe70d4c707 fix(release): pass a GoReleaser timeout of 60 minutes
The continue command that merges split builds stops after 30 minutes by
default. A slow multi-architecture docker build under QEMU hit that
limit and the superclaw release failed with context canceled after
30 minutes.

The goreleaser action takes a timeout input, default 60m, and passes it
to release, release --split and continue --merge. The release workflows
expose it as release-timeout.
2026-10-03 00:05:15 +01:00
lukaszraczylo ac32cb03c4 feat(release): make the QEMU binfmt image an input
The default stays qemu-v8.1.5. A caller whose arm64 build crashes under that version can pass another image.
2026-10-02 15:49:16 +01:00
lukaszraczylo a6a9c55bdf fix(release): pin QEMU binfmt image to qemu-v8.1.5
The unpinned latest binfmt image can crash arm64 apt installs with a segmentation fault.
Pin the image to a known stable release for reproducible multi-arch builds.
2026-10-02 11:17:50 +01:00
lukaszraczylo 9a042ecafd fix(go-pr): read only the total line of the coverage report
The coverage step matched every line that contains the word total, so a
function name such as subtotal added a second value. GitHub then
rejected the multi-line step output. The step now reads the line that
starts with total:.
2026-10-02 04:33:59 +01:00
lukaszraczylo 5390791174 fix(go-autoupdate): fall back to direct dependencies when a full update breaks the build
go get -u updates indirect modules past the versions their parents
support. For Kubernetes projects the build then fails and the daily
update has failed since early September. The step now builds after a
full update. On failure it restores go.mod and go.sum and updates the
direct dependencies only.

The Go toolchain also uses check-latest so a range such as >=1.24
resolves to the newest release.
2026-10-02 02:48:47 +01:00
lukaszraczylo 4f53b0593e fix(go-pr): skip the report job when the run is cancelled
A run cancelled by a newer push still ran the report job. The gate then
failed and marked the superseded run as failed. The report job now
skips when the run is cancelled.
2026-10-02 02:35:03 +01:00
lukaszraczylo c9823f320e fix(workflows): parenthesise the runner fallback chain
The && operator bound to the first operand only, so the label branch
returned the bare label before the format call. Parentheses keep the
whole fallback chain on the && branch.
2026-10-02 01:32:09 +01:00
lukaszraczylo 5bc07d18ae fix(workflows): quote the runner label with format instead of toJSON
GitHub rejected the runs-on expression: fromJSON received the bare
label. The expression now wraps the label in quotes with format.
2026-10-02 01:31:55 +01:00
lukaszraczylo 9533a6518f feat(preflight): probe release and ghcr push access
A caller can grant narrower permissions than the release job needs. The
release then fails at the end, when it creates the tag or pushes an
image.

The preflight job now declares the same contents and packages
permissions as the release job and probes with the real token. A git
push dry run checks that the token can create the tag and release. A
ghcr.io token request and a blob upload session check that the token can
push the image. The upload session creates no package.

The release-cgo workflow probes ghcr only when the GoReleaser config
builds ghcr.io images. The release workflow probes it when
docker-enabled is true and the registry is ghcr.io. A probe that cannot
reach a verdict warns and does not fail.
2026-10-02 01:27:01 +01:00
lukaszraczylo f9b22ef8d9 feat(preflight): fail early when a secret or repository access is missing
A release run used to fail at the GoReleaser step when a secret was
empty, after the tests and the version job had finished.

A new preflight action checks the required names, the .Env references
in the GoReleaser config and push access to optional repositories. It
lists every problem in one run. The release and release-cgo workflows
run it in a first job, so a missing secret fails in seconds.

The release-cgo workflow requires GORELEASER_PRO. The release workflow
requires the secrets that the GoReleaser config references. Set the
preflight input to false to skip the check. Set preflight-repos to check
access to a Homebrew tap or helm-charts repository.
2026-10-02 01:23:56 +01:00
lukaszraczylo 6b47cbd317 feat(go-pr): add runner input and a single PR checks report
The workflow takes the same runner input as the release workflows.

Findings from go vet and staticcheck now appear as inline annotations
on the pull request diff. A new report job posts one comment with the
result of every check and the coverage. It fails when a check fails or
coverage is below the threshold. It no longer needs the bc command.

A push to a branch with an open pull request now skips its run, because
the pull_request event already runs the checks. Stale runs on the same
branch are cancelled. Every job has a timeout.
2026-10-02 01:20:26 +01:00
lukaszraczylo cce6b9d88c feat(workflows): add runner input, timeouts and release concurrency
The release, release-cgo and autoupdate workflows must run on a runner
the caller chooses. Each workflow now takes a runner input. It accepts
a label or a JSON object that selects a runner group.

The workflow uses inputs.runner first. It then uses the
SHARED_ACTIONS_RUNNER variable and falls back to ubuntu-latest. Callers
that set nothing keep the old behaviour.

Every job now has a timeout. Release and autoupdate runs use a
concurrency group that never cancels a running job.
2026-10-02 01:20:24 +01:00
lukaszraczylo 5cf1402555 fix(go-autoupdate): fall back to github.token when HOMEBREW_TAP_TOKEN is unset
create-pull-request and the gh pr merge step both hard-required HOMEBREW_TAP_TOKEN,
so any consumer repo without that secret got 'Input token not supplied' and the
whole Autoupdate run failed. A same-repo PR needs only the caller's
contents/pull-requests write; fall back to the auto-provisioned github.token.
Consumers that do set the tap token keep current behavior.
2026-08-10 10:25:57 +01:00
lukaszraczylo 72ef4441eb feat(go-release): add optional runner input (default ubuntu-latest)
Lets a caller run the release jobs on a self-hosted runner via with.runner,
e.g. runner: self-hosted. Defaults to ubuntu-latest so existing consumers are
unaffected.
2026-06-18 13:36:28 +01:00
8 changed files with 915 additions and 76 deletions
+7 -3
View File
@@ -20,6 +20,10 @@ inputs:
description: "GoReleaser Pro license key (for split/merge)"
required: false
default: ""
timeout:
description: "GoReleaser timeout for the whole step (Go duration, e.g. 60m). The continue command defaults to 30m, which cuts off slow multi-architecture docker builds."
required: false
default: "60m"
homebrew-tap-token:
description: "Homebrew tap token (optional)"
required: false
@@ -59,7 +63,7 @@ runs:
with:
distribution: goreleaser
version: "~> v2"
args: release --clean
args: release --clean --timeout ${{ inputs.timeout }}
env:
GITHUB_TOKEN: ${{ inputs.github-token }}
HOMEBREW_TAP_TOKEN: ${{ inputs.homebrew-tap-token }}
@@ -71,7 +75,7 @@ runs:
with:
distribution: goreleaser-pro
version: "~> v2"
args: release --clean --split
args: release --clean --split --timeout ${{ inputs.timeout }}
env:
GITHUB_TOKEN: ${{ inputs.github-token }}
GORELEASER_KEY: ${{ inputs.goreleaser-key }}
@@ -83,7 +87,7 @@ runs:
with:
distribution: goreleaser-pro
version: "~> v2"
args: continue --merge
args: continue --merge --timeout ${{ inputs.timeout }}
env:
GITHUB_TOKEN: ${{ inputs.github-token }}
GORELEASER_KEY: ${{ inputs.goreleaser-key }}
+163
View File
@@ -0,0 +1,163 @@
name: "Preflight"
description: "Fail early when a required secret, env var or repository access is missing"
inputs:
require:
description: "Comma-separated names that must be set and non-empty in the step env"
required: false
default: ""
goreleaser-scan:
description: "Also require every .Env.NAME referenced by the GoReleaser config"
required: false
default: "true"
repo-access:
description: "Comma-separated owner/repo list the token must have push access to"
required: false
default: ""
repo-token-env:
description: "Name of the env var that holds the token for repo-access"
required: false
default: "HOMEBREW_TAP_TOKEN"
release-write:
description: "Probe that GITHUB_TOKEN can push to this repository (needed to create tags and releases)"
required: false
default: "false"
ghcr-push:
description: "Probe push access to ghcr.io: true, false, or auto (only when the GoReleaser config builds ghcr.io images)"
required: false
default: "false"
ghcr-image:
description: "Image path (owner/name) for the ghcr probe. Default: first ghcr.io path in the GoReleaser config, else the repository"
required: false
default: ""
ghcr-token-env:
description: "Name of the env var that holds the ghcr token"
required: false
default: "GITHUB_TOKEN"
runs:
using: "composite"
steps:
- name: Check prerequisites
shell: bash
env:
PF_REQUIRE: ${{ inputs.require }}
PF_SCAN: ${{ inputs.goreleaser-scan }}
PF_REPOS: ${{ inputs.repo-access }}
PF_TOKEN_ENV: ${{ inputs.repo-token-env }}
PF_RELEASE_WRITE: ${{ inputs.release-write }}
PF_GHCR: ${{ inputs.ghcr-push }}
PF_GHCR_IMAGE: ${{ inputs.ghcr-image }}
PF_GHCR_TOKEN_ENV: ${{ inputs.ghcr-token-env }}
run: |
missing=()
add_missing() { # name reason
case " ${missing[*]-} " in *" $1 "*) return ;; esac
missing+=("$1")
echo "::error title=Missing $1::$2"
}
is_set() { [ -n "${!1:-}" ]; }
IFS=',' read -r -a required <<< "$PF_REQUIRE"
for name in "${required[@]-}"; do
name="${name// /}"
[ -z "$name" ] && continue
is_set "$name" || add_missing "$name" "Required by this workflow but empty. Set the repository or organisation secret $name and make sure the caller passes 'secrets: inherit'."
done
if [ "$PF_SCAN" = "true" ]; then
for cfg in .goreleaser.yaml .goreleaser.yml goreleaser.yaml goreleaser.yml; do
[ -f "$cfg" ] || continue
echo "Scanning $cfg for .Env references"
while IFS= read -r ref; do
name="${ref#.Env.}"
is_set "$name" || add_missing "$name" "$cfg references .Env.$name but it is not available to the GoReleaser step. GoReleaser would fail at the end of the run."
done < <(grep -vE '^[[:space:]]*#' "$cfg" | grep -oE '\.Env\.[A-Za-z_][A-Za-z0-9_]*' | sort -u)
break
done
fi
if [ -n "$PF_REPOS" ]; then
token="${!PF_TOKEN_ENV:-}"
if [ -z "$token" ]; then
add_missing "$PF_TOKEN_ENV" "repo-access needs this token but it is empty."
else
IFS=',' read -r -a repos <<< "$PF_REPOS"
for repo in "${repos[@]}"; do
repo="${repo// /}"
[ -z "$repo" ] && continue
body="$(mktemp)"
code="$(curl -sS -o "$body" -w '%{http_code}' \
-H "Authorization: Bearer $token" \
-H "Accept: application/vnd.github+json" \
"${GITHUB_API_URL:-https://api.github.com}/repos/$repo")" || code="000"
if [ "$code" != "200" ]; then
add_missing "access:$repo" "$PF_TOKEN_ENV cannot read $repo (HTTP $code). Check the token scope, expiry and repository access."
elif ! grep -qE '"push": *true' "$body"; then
add_missing "access:$repo" "$PF_TOKEN_ENV can read $repo but has no push access."
fi
rm -f "$body"
done
fi
fi
if [ "$PF_RELEASE_WRITE" = "true" ]; then
out="$(git push --dry-run origin HEAD:refs/heads/preflight-probe 2>&1)" && rc=0 || rc=$?
if [ "$rc" -ne 0 ]; then
if printf '%s' "$out" | grep -qiE 'denied|403|401|not permitted|could not read Username'; then
add_missing "contents:write" "The token cannot push to this repository, so it cannot create the tag or release. Grant 'contents: write' in the caller workflow permissions and check the repository Actions settings. Git said: $(printf '%s' "$out" | tail -n 1)"
else
echo "::warning title=Push probe inconclusive::$(printf '%s' "$out" | tail -n 1)"
fi
fi
fi
ghcr_wanted="$PF_GHCR"
cfg_found=""
for cfg in .goreleaser.yaml .goreleaser.yml goreleaser.yaml goreleaser.yml; do
[ -f "$cfg" ] && { cfg_found="$cfg"; break; }
done
if [ "$ghcr_wanted" = "auto" ]; then
ghcr_wanted=false
if [ -n "$cfg_found" ] && grep -qE '^(dockers|dockers_v2|docker_manifests):' "$cfg_found" && grep -q 'ghcr\.io' "$cfg_found"; then
ghcr_wanted=true
fi
fi
if [ "$ghcr_wanted" = "true" ]; then
gtoken="${!PF_GHCR_TOKEN_ENV:-}"
image="$PF_GHCR_IMAGE"
if [ -z "$image" ] && [ -n "$cfg_found" ]; then
image="$(grep -oE 'ghcr\.io/[A-Za-z0-9._/-]+' "$cfg_found" | head -n 1 | sed 's|^ghcr\.io/||')"
fi
image="${image:-$GITHUB_REPOSITORY}"
image="$(printf '%s' "$image" | tr '[:upper:]' '[:lower:]')"
if [ -z "$gtoken" ]; then
add_missing "$PF_GHCR_TOKEN_ENV" "ghcr push needs this token but it is empty."
else
tbody="$(mktemp)"
code="$(curl -sS -o "$tbody" -w '%{http_code}' -u "${GITHUB_ACTOR:-token}:$gtoken" \
"https://ghcr.io/token?service=ghcr.io&scope=repository:$image:push,pull")" || code="000"
btoken="$(sed -n 's/.*"token" *: *"\([^"]*\)".*/\1/p' "$tbody" | head -n 1)"
rm -f "$tbody"
if [ "$code" = "200" ] && [ -n "$btoken" ]; then
ucode="$(curl -sS -o /dev/null -w '%{http_code}' -X POST -H "Authorization: Bearer $btoken" \
"https://ghcr.io/v2/$image/blobs/uploads/")" || ucode="000"
case "$ucode" in
202) ;;
401|403) add_missing "packages:write" "The token cannot push ghcr.io/$image (HTTP $ucode). Grant 'packages: write' in the caller workflow permissions, and for an existing package allow this repository under Package settings, Manage Actions access." ;;
*) echo "::warning title=ghcr probe inconclusive::upload session for $image returned HTTP $ucode" ;;
esac
elif [ "$code" = "401" ] || [ "$code" = "403" ]; then
add_missing "packages:write" "ghcr.io refused the token for $image (HTTP $code). Grant 'packages: write' in the caller workflow permissions."
else
echo "::warning title=ghcr probe inconclusive::token request for $image returned HTTP $code"
fi
fi
fi
if [ "${#missing[@]}" -gt 0 ]; then
echo "Preflight failed: ${missing[*]}"
exit 1
fi
echo "Preflight passed"
+29 -4
View File
@@ -19,11 +19,22 @@ on:
type: boolean
default: false
runner:
description: "Runner label, or JSON object such as {\"group\":\"ci\",\"labels\":[\"linux\"]} to select a runner group. Falls back to vars.SHARED_ACTIONS_RUNNER, then ubuntu-latest."
required: false
type: string
default: ""
concurrency:
group: autoupdate-${{ github.repository }}-${{ github.ref }}
cancel-in-progress: false
jobs:
autoupdate:
name: Update dependencies and test
runs-on: ubuntu-latest
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
steps:
- name: Checkout
uses: actions/checkout@v6
@@ -43,12 +54,26 @@ jobs:
uses: actions/setup-go@v6
with:
go-version: ${{ inputs.go-version }}
check-latest: true
cache: true
- name: Update dependencies
shell: bash
run: |
go get -u ./...
# Indirect modules can move ahead of their parents (for example
# kube-openapi ahead of apimachinery), which breaks the build.
update_all() { go get -u ./... && go mod tidy && go build ./...; }
update_direct() {
local mods=()
while IFS= read -r m; do mods+=("$m"); done < <(go list -m -f '{{if and (not .Indirect) (not .Main)}}{{.Path}}@latest{{end}}' all)
if [ "${#mods[@]}" -gt 0 ]; then go get "${mods[@]}"; fi
go mod tidy
}
if ! update_all; then
echo "::warning title=Full update failed::Falling back to direct dependencies only"
git checkout -- go.mod go.sum
update_direct
fi
- name: Run tests
run: go test -race -cover ./...
@@ -67,7 +92,7 @@ jobs:
if: steps.changes.outputs.changes_detected == 'true'
uses: peter-evans/create-pull-request@v8
with:
token: ${{ secrets.HOMEBREW_TAP_TOKEN }}
token: ${{ secrets.HOMEBREW_TAP_TOKEN || github.token }}
commit-message: "Update go.mod and go.sum"
title: "Update Go dependencies"
body: |
@@ -82,4 +107,4 @@ jobs:
if: steps.create-pr.outputs.pull-request-number
run: gh pr merge --squash --delete-branch --admin "${{ steps.create-pr.outputs.pull-request-number }}"
env:
GH_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }}
GH_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN || github.token }}
+133 -59
View File
@@ -24,16 +24,58 @@ on:
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: ""
# Caller must declare these permissions:
# permissions:
# contents: read
# pull-requests: write
# security-events: write
concurrency:
group: go-pr-${{ github.repository }}-${{ github.event_name }}-${{ github.head_ref || github.ref_name }}
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
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.');
static-analysis:
name: Static Analysis
runs-on: ubuntu-latest
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')) }}
needs: pre
if: needs.pre.outputs.skip != 'true'
timeout-minutes: 15
steps:
- name: Checkout
uses: actions/checkout@v6
@@ -58,6 +100,14 @@ jobs:
- name: Install staticcheck
run: go install honnef.co/go/tools/cmd/staticcheck@latest
- name: Register problem matcher
shell: bash
run: |
cat > "$RUNNER_TEMP/go-problem-matcher.json" <<'JSON'
{"problemMatcher":[{"owner":"go-lint","severity":"error","pattern":[{"regexp":"^(?:vet: )?([^\\s:]+\\.go):(\\d+):(\\d+): (.+)$","file":1,"line":2,"column":3,"message":4}]}]}
JSON
echo "::add-matcher::$RUNNER_TEMP/go-problem-matcher.json"
- name: Run go vet
run: go vet ./...
@@ -66,7 +116,10 @@ jobs:
security-scan:
name: Security Scan
runs-on: ubuntu-latest
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')) }}
needs: pre
if: needs.pre.outputs.skip != 'true'
timeout-minutes: 20
steps:
- name: Checkout
uses: actions/checkout@v6
@@ -123,7 +176,10 @@ jobs:
gosec:
name: Gosec SARIF
runs-on: ubuntu-latest
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')) }}
needs: pre
if: needs.pre.outputs.skip != 'true'
timeout-minutes: 15
permissions:
contents: read
security-events: write
@@ -164,7 +220,10 @@ jobs:
goreleaser-check:
name: GoReleaser Config Validation
runs-on: ubuntu-latest
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')) }}
needs: pre
if: needs.pre.outputs.skip != 'true'
timeout-minutes: 10
steps:
- name: Checkout
uses: actions/checkout@v6
@@ -196,7 +255,10 @@ jobs:
codeql:
name: CodeQL Analysis
runs-on: ubuntu-latest
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')) }}
needs: pre
if: needs.pre.outputs.skip != 'true'
timeout-minutes: 45
permissions:
contents: read
security-events: write
@@ -233,7 +295,10 @@ jobs:
test:
name: Tests & Coverage
runs-on: ubuntu-latest
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')) }}
needs: pre
if: needs.pre.outputs.skip != 'true'
timeout-minutes: 30
outputs:
coverage: ${{ steps.coverage.outputs.coverage }}
steps:
@@ -272,70 +337,79 @@ jobs:
- name: Calculate coverage
id: coverage
run: |
COVERAGE=$(go tool cover -func=coverage.out | grep total | awk '{print $3}' | sed 's/%//')
COVERAGE=$(go tool cover -func=coverage.out | awk '/^total:/ {print $3}' | sed 's/%//')
echo "coverage=$COVERAGE" >> $GITHUB_OUTPUT
echo "Total Coverage: $COVERAGE%"
coverage-report:
name: Coverage Report
runs-on: ubuntu-latest
needs: test
if: always() && needs.test.result == 'success'
report:
name: PR Checks Report
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')) }}
needs: [pre, static-analysis, security-scan, gosec, goreleaser-check, codeql, test]
if: ${{ !cancelled() && needs.pre.outputs.skip != 'true' }}
timeout-minutes: 10
permissions:
contents: read
pull-requests: write
steps:
- name: Comment coverage on PR
if: github.event_name == 'pull_request'
- name: Summarise, comment and gate
uses: actions/github-script@v9
env:
NEEDS: ${{ toJSON(needs) }}
THRESHOLD: ${{ inputs.coverage-threshold }}
with:
script: |
const coverage = '${{ needs.test.outputs.coverage }}';
const threshold = ${{ inputs.coverage-threshold }};
const coverageNum = parseFloat(coverage);
const hasThreshold = threshold > 0;
const meetsThreshold = !hasThreshold || coverageNum >= threshold;
const emoji = meetsThreshold ? '✅' : '⚠️';
const status = hasThreshold ? (meetsThreshold ? 'meets' : 'below') + ' threshold' : 'reported';
let body = `## ${emoji} Test Coverage Report\n\n| Metric | Value |\n|--------|-------|\n| **Total Coverage** | ${coverage}% |`;
if (hasThreshold) {
body += `\n| **Threshold** | ${threshold}% |\n| **Status** | ${emoji} Coverage ${status} |`;
const needs = JSON.parse(process.env.NEEDS);
const threshold = Number(process.env.THRESHOLD);
const labels = {
'static-analysis': 'Static analysis (vet, staticcheck)',
'security-scan': 'Security scan (TruffleHog, govulncheck)',
'gosec': 'Gosec',
'goreleaser-check': 'GoReleaser config',
'codeql': 'CodeQL',
'test': 'Tests (race) & coverage',
};
const icon = { success: '✅', failure: '❌', cancelled: '🚫', skipped: '⏭️' };
const failed = [];
let body = '<!-- go-pr-report -->\n## PR checks\n\n| Check | Result |\n|---|---|\n';
for (const [key, label] of Object.entries(labels)) {
const result = needs[key]?.result ?? 'skipped';
if (result === 'failure' || result === 'cancelled') failed.push(label);
body += `| ${label} | ${icon[result] ?? result} ${result} |\n`;
}
const { data: comments } = await github.rest.issues.listComments({
const coverage = parseFloat(needs.test?.outputs?.coverage);
const hasCoverage = !Number.isNaN(coverage);
if (hasCoverage) {
body += `\n**Total coverage:** ${coverage}%`;
if (threshold > 0) {
const ok = coverage >= threshold;
body += ` (threshold ${threshold}%: ${ok ? '✅ met' : '❌ below'})`;
if (!ok) failed.push(`Coverage ${coverage}% below ${threshold}%`);
}
body += '\n';
}
body += `\n[Workflow run](${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId})`;
await core.summary.addRaw(body, true).write();
if (context.eventName === 'pull_request') {
try {
const comments = await github.paginate(github.rest.issues.listComments, {
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
});
const botComment = comments.find(comment =>
comment.user.type === 'Bot' &&
comment.body.includes('Test Coverage Report')
);
if (botComment) {
await github.rest.issues.updateComment({
comment_id: botComment.id,
owner: context.repo.owner,
repo: context.repo.repo,
body: body
});
const existing = comments.find(c => c.user.type === 'Bot' &&
(c.body.includes('<!-- go-pr-report -->') || c.body.includes('Test Coverage Report')));
const args = { owner: context.repo.owner, repo: context.repo.repo, body };
if (existing) {
await github.rest.issues.updateComment({ ...args, comment_id: existing.id });
} else {
await github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: body
});
await github.rest.issues.createComment({ ...args, issue_number: context.issue.number });
}
} catch (e) {
core.warning(`Could not post PR comment (fork PR or missing pull-requests: write): ${e.message}`);
}
}
- name: Check coverage threshold
if: inputs.coverage-threshold > 0
run: |
COVERAGE=${{ needs.test.outputs.coverage }}
THRESHOLD=${{ inputs.coverage-threshold }}
echo "Coverage: $COVERAGE%"
echo "Threshold: $THRESHOLD%"
if (( $(echo "$COVERAGE < $THRESHOLD" | bc -l) )); then
echo "❌ Coverage $COVERAGE% is below threshold $THRESHOLD%"
exit 1
fi
echo "✅ Coverage $COVERAGE% meets threshold $THRESHOLD%"
if (failed.length) core.setFailed(`Failed: ${failed.join('; ')}`);
+64 -4
View File
@@ -60,6 +60,26 @@ on:
required: false
type: boolean
default: false
runner:
description: "Runner label, or JSON object such as {\"group\":\"ci\",\"labels\":[\"linux\"]} to select a runner group. Falls back to vars.SHARED_ACTIONS_RUNNER, then ubuntu-latest."
required: false
type: string
default: ""
preflight:
description: "Fail early when a required secret, env var or GoReleaser .Env reference is missing"
required: false
type: boolean
default: true
preflight-repos:
description: "Comma-separated owner/repo list the token must have push access to (e.g. a Homebrew tap or helm-charts repo)"
required: false
type: string
default: ""
release-timeout:
description: "GoReleaser timeout for the release step (Go duration)"
required: false
type: string
default: "60m"
outputs:
version:
description: "The calculated version (without v prefix)"
@@ -68,10 +88,43 @@ on:
description: "The version tag (with v prefix)"
value: ${{ jobs.version.outputs.version_tag }}
concurrency:
group: release-cgo-${{ github.repository }}-${{ github.ref }}
cancel-in-progress: false
jobs:
preflight:
name: Preflight
permissions:
contents: write
packages: write
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
steps:
- name: Checkout
if: inputs.preflight
uses: actions/checkout@v6
with:
lfs: false
- name: Check secrets and access
if: inputs.preflight
uses: lukaszraczylo/shared-actions/.github/actions/preflight@main
with:
require: GITHUB_TOKEN,GORELEASER_KEY
release-write: "true"
ghcr-push: auto
repo-access: ${{ inputs.preflight-repos }}
repo-token-env: GITHUB_TOKEN
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GORELEASER_KEY: ${{ secrets.GORELEASER_PRO }}
test:
name: Test
runs-on: ubuntu-latest
needs: preflight
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
steps:
- name: Checkout
uses: actions/checkout@v6
@@ -95,8 +148,10 @@ jobs:
frontend:
name: Build Frontend
needs: preflight
if: inputs.node-enabled && inputs.node-build-script != ''
runs-on: ubuntu-latest
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
steps:
- name: Checkout
uses: actions/checkout@v6
@@ -132,10 +187,11 @@ jobs:
version:
name: Calculate Version
needs: test
runs-on: ubuntu-latest
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')) }}
outputs:
version: ${{ steps.semver.outputs.version }}
version_tag: ${{ steps.semver.outputs.version_tag }}
timeout-minutes: 10
steps:
- name: Checkout
uses: actions/checkout@v6
@@ -170,6 +226,7 @@ jobs:
matrix:
include: ${{ fromJson(inputs.platforms) }}
runs-on: ${{ matrix.os }}
timeout-minutes: 60
steps:
- name: Checkout
uses: actions/checkout@v6
@@ -223,6 +280,7 @@ jobs:
uses: lukaszraczylo/shared-actions/.github/actions/goreleaser@main
with:
version-tag: ${{ needs.version.outputs.version_tag }}
timeout: ${{ inputs.release-timeout }}
mode: split
cgo-enabled: "1"
github-token: ${{ secrets.GITHUB_TOKEN }}
@@ -242,11 +300,12 @@ jobs:
always() &&
needs.version.result == 'success' &&
needs.build.result == 'success'
runs-on: ubuntu-latest
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')) }}
permissions:
contents: write
packages: write
id-token: write
timeout-minutes: 60
steps:
- name: Checkout
uses: actions/checkout@v6
@@ -301,6 +360,7 @@ jobs:
uses: lukaszraczylo/shared-actions/.github/actions/goreleaser@main
with:
version-tag: ${{ needs.version.outputs.version_tag }}
timeout: ${{ inputs.release-timeout }}
mode: merge
github-token: ${{ secrets.GITHUB_TOKEN }}
goreleaser-key: ${{ secrets.GORELEASER_PRO }}
+65 -3
View File
@@ -18,6 +18,11 @@ on:
required: false
type: boolean
default: false
qemu-image:
description: "binfmt image that registers QEMU for multi-arch Docker builds"
required: false
type: string
default: "tonistiigi/binfmt:qemu-v8.1.5"
docker-registry:
description: "Docker registry to push to"
required: false
@@ -33,6 +38,26 @@ on:
required: false
type: boolean
default: false
runner:
description: "Runner label, or JSON object such as {\"group\":\"ci\",\"labels\":[\"linux\"]} to select a runner group. Falls back to vars.SHARED_ACTIONS_RUNNER, then ubuntu-latest."
required: false
type: string
default: ""
preflight:
description: "Fail early when a required secret, env var or GoReleaser .Env reference is missing"
required: false
type: boolean
default: true
preflight-repos:
description: "Comma-separated owner/repo list the token must have push access to (e.g. a Homebrew tap or helm-charts repo)"
required: false
type: string
default: ""
release-timeout:
description: "GoReleaser timeout for the release step (Go duration)"
required: false
type: string
default: "60m"
outputs:
version:
description: "The calculated version (without v prefix)"
@@ -41,10 +66,42 @@ on:
description: "The version tag (with v prefix)"
value: ${{ jobs.version.outputs.version_tag }}
concurrency:
group: release-${{ github.repository }}-${{ github.ref }}
cancel-in-progress: false
jobs:
preflight:
name: Preflight
permissions:
contents: write
packages: write
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
steps:
- name: Checkout
if: inputs.preflight
uses: actions/checkout@v6
with:
lfs: false
- name: Check secrets and access
if: inputs.preflight
uses: lukaszraczylo/shared-actions/.github/actions/preflight@main
with:
require: GITHUB_TOKEN
release-write: "true"
ghcr-push: ${{ inputs.docker-enabled && inputs.docker-registry == 'ghcr.io' && 'true' || 'false' }}
repo-access: ${{ inputs.preflight-repos }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
HOMEBREW_TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }}
test:
name: Test
runs-on: ubuntu-latest
needs: preflight
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
steps:
- name: Checkout
uses: actions/checkout@v6
@@ -67,10 +124,11 @@ jobs:
version:
name: Calculate Version
needs: test
runs-on: ubuntu-latest
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')) }}
outputs:
version: ${{ steps.semver.outputs.version }}
version_tag: ${{ steps.semver.outputs.version_tag }}
timeout-minutes: 10
steps:
- name: Checkout
uses: actions/checkout@v6
@@ -96,11 +154,12 @@ jobs:
name: Release
needs: version
if: needs.version.outputs.version_tag != ''
runs-on: ubuntu-latest
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')) }}
permissions:
contents: write
packages: write
id-token: write
timeout-minutes: 60
steps:
- name: Checkout
uses: actions/checkout@v6
@@ -133,6 +192,8 @@ jobs:
- name: Set up QEMU
if: inputs.docker-enabled
uses: docker/setup-qemu-action@v4
with:
image: ${{ inputs.qemu-image }}
- name: Set up Docker Buildx
if: inputs.docker-enabled
@@ -150,6 +211,7 @@ jobs:
uses: lukaszraczylo/shared-actions/.github/actions/goreleaser@main
with:
version-tag: ${{ needs.version.outputs.version_tag }}
timeout: ${{ inputs.release-timeout }}
mode: full
github-token: ${{ secrets.GITHUB_TOKEN }}
homebrew-tap-token: ${{ secrets.HOMEBREW_TAP_TOKEN }}
+337
View File
@@ -0,0 +1,337 @@
name: Renovate Autoupdate
# Renovate batches every Go and npm/pnpm/yarn update into one PR, the tests run
# on that branch, and the PR merges only when they pass. Replaces go-autoupdate.
# A renovate.json in the calling repository adds to these defaults.
on:
workflow_call:
inputs:
go-version:
description: "Go version to use"
required: false
type: string
default: ">=1.24"
go-working-directory:
description: "Directory that holds go.mod"
required: false
type: string
default: "."
node-version:
description: "Node.js version to use"
required: false
type: string
default: "22"
node-working-directory:
description: "Directory that holds package.json"
required: false
type: string
default: "."
node-scripts:
description: "Comma-separated package.json scripts to run, in order. A script that does not exist is skipped."
required: false
type: string
default: "lint,typecheck,test,build"
require-node-tests:
description: "Fail when a Node project has no test script, so an untested update never merges"
required: false
type: boolean
default: true
include-major:
description: "Put major updates in the batch. When false, each major update gets its own PR that is never merged automatically."
required: false
type: boolean
default: false
minimum-release-age:
description: "Skip releases younger than this, for example '2 days'"
required: false
type: string
default: "2 days"
renovate-version:
description: "Renovate version for the action"
required: false
type: string
default: "44"
commit-subject:
description: "Squash commit subject. Keep it free of release keywords such as 'major' or 'breaking', which semver-generator matches."
required: false
type: string
default: "chore(deps): update dependencies"
admin-merge:
description: "Merge with --admin, which bypasses branch protection. The workflow token cannot do this, so leave it off unless you pass a token that can."
required: false
type: boolean
default: false
release-workflow:
description: "Workflow file to dispatch after the merge, for example release.yaml. It must have a workflow_dispatch trigger and the caller needs actions: write. Empty means no dispatch."
required: false
type: string
default: ""
lfs:
description: "Enable Git LFS checkout (for repos with large files)"
required: false
type: boolean
default: false
runner:
description: "Runner label, or JSON object such as {\"group\":\"ci\",\"labels\":[\"linux\"]} to select a runner group. Falls back to vars.SHARED_ACTIONS_RUNNER, then ubuntu-latest."
required: false
type: string
default: ""
concurrency:
group: renovate-autoupdate-${{ github.repository }}
cancel-in-progress: false
env:
BATCH_BRANCH: deps-autoupdate/batch
jobs:
renovate:
name: Update dependencies
runs-on: ${{ fromJSON(startsWith(inputs.runner || vars.SHARED_ACTIONS_RUNNER || 'ubuntu-latest', '{') && (inputs.runner || vars.SHARED_ACTIONS_RUNNER || 'ubuntu-latest') || format('"{0}"', inputs.runner || vars.SHARED_ACTIONS_RUNNER || 'ubuntu-latest')) }}
timeout-minutes: 20
permissions:
contents: write
pull-requests: write
outputs:
pr-number: ${{ steps.find-pr.outputs.pr-number }}
steps:
- name: Write Renovate config
env:
MIN_AGE: ${{ inputs.minimum-release-age }}
INCLUDE_MAJOR: ${{ inputs.include-major }}
CONFIG_FILE: ${{ runner.temp }}/renovate-autoupdate-config.json
run: |
jq -n \
--arg repo "$GITHUB_REPOSITORY" \
--arg age "$MIN_AGE" \
--arg branch_prefix "${BATCH_BRANCH%%/*}/" \
--argjson include_major "$INCLUDE_MAJOR" \
'
{
platform: "github",
repositories: [$repo],
onboarding: false,
requireConfig: "optional",
branchPrefix: $branch_prefix,
enabledManagers: ["gomod", "npm"],
dependencyDashboard: false,
semanticCommits: "enabled",
semanticCommitType: "chore",
semanticCommitScope: "deps",
minimumReleaseAge: $age,
prConcurrentLimit: 0,
prHourlyLimit: 0,
recreateWhen: "always",
postUpdateOptions: ["gomodTidy"],
statusCheckNames: {
artifactError: null,
configValidation: null,
mergeConfidence: null,
minimumReleaseAge: null
},
packageRules: [
({
matchManagers: ["gomod", "npm"],
groupName: "dependencies",
groupSlug: "batch"
} + (if $include_major then {} else {matchUpdateTypes: ["minor", "patch", "pin", "pinDigest", "digest"]} end)),
{matchManagers: ["gomod"], matchDepTypes: ["indirect"], enabled: true},
{matchPackageNames: ["typescript"], allowedVersions: "<7"}
]
}' > "$CONFIG_FILE"
jq . "$CONFIG_FILE"
- name: Run Renovate
uses: renovatebot/[email protected]
with:
configurationFile: ${{ runner.temp }}/renovate-autoupdate-config.json
token: ${{ github.token }}
renovate-version: ${{ inputs.renovate-version }}
env:
LOG_LEVEL: info
- name: Find the batch PR
id: find-pr
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
test:
name: Test the update
needs: renovate
if: needs.renovate.outputs.pr-number != ''
runs-on: ${{ fromJSON(startsWith(inputs.runner || vars.SHARED_ACTIONS_RUNNER || 'ubuntu-latest', '{') && (inputs.runner || vars.SHARED_ACTIONS_RUNNER || 'ubuntu-latest') || format('"{0}"', inputs.runner || vars.SHARED_ACTIONS_RUNNER || 'ubuntu-latest')) }}
timeout-minutes: 45
permissions:
contents: read
pull-requests: write
outputs:
head-sha: ${{ steps.checkout-sha.outputs.sha }}
steps:
- name: Checkout the batch branch
uses: actions/checkout@v6
with:
ref: ${{ env.BATCH_BRANCH }}
token: ${{ github.token }}
lfs: ${{ inputs.lfs }}
- name: Record the tested commit
id: checkout-sha
run: echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
- name: Run workflow prepare script
shell: bash
run: |
if [ -f "./workflow-prepare.sh" ]; then
chmod +x ./workflow-prepare.sh
./workflow-prepare.sh
fi
- name: Detect project types
id: detect
env:
GO_DIR: ${{ inputs.go-working-directory }}
NODE_DIR: ${{ inputs.node-working-directory }}
run: |
go=false
node=false
pm=npm
if [ -f "$GO_DIR/go.mod" ]; then go=true; fi
if [ -f "$NODE_DIR/package.json" ]; then
node=true
if [ -f "$NODE_DIR/pnpm-lock.yaml" ]; then pm=pnpm
elif [ -f "$NODE_DIR/yarn.lock" ]; then pm=yarn
fi
fi
if [ "$go" = false ] && [ "$node" = false ]; then
echo "::error::Neither $GO_DIR/go.mod nor $NODE_DIR/package.json exists, so there is nothing to test."
exit 1
fi
{
echo "go=$go"
echo "node=$node"
echo "pm=$pm"
} >> "$GITHUB_OUTPUT"
- name: Setup Go
if: steps.detect.outputs.go == 'true'
uses: actions/setup-go@v6
with:
go-version: ${{ inputs.go-version }}
check-latest: true
cache: true
cache-dependency-path: ${{ inputs.go-working-directory }}/go.sum
- name: Build and test Go
if: steps.detect.outputs.go == 'true'
working-directory: ${{ inputs.go-working-directory }}
run: |
go mod verify
go build ./...
go test -race -cover ./...
- name: Setup Node.js
if: steps.detect.outputs.node == 'true'
uses: actions/setup-node@v6
with:
node-version: ${{ inputs.node-version }}
- name: Install Node dependencies
if: steps.detect.outputs.node == 'true'
working-directory: ${{ inputs.node-working-directory }}
env:
PM: ${{ steps.detect.outputs.pm }}
run: |
# The lockfile must match what Renovate wrote: install without updating it.
case "$PM" in
pnpm | yarn)
command -v corepack >/dev/null || npm install --global corepack
corepack enable "$PM"
;;
esac
case "$PM" in
pnpm) pnpm install --frozen-lockfile ;;
yarn) yarn install --immutable ;;
npm)
if [ -f package-lock.json ]; then npm ci; else npm install; fi
;;
esac
- name: Run Node scripts
if: steps.detect.outputs.node == 'true'
working-directory: ${{ inputs.node-working-directory }}
env:
PM: ${{ steps.detect.outputs.pm }}
SCRIPTS: ${{ inputs.node-scripts }}
REQUIRE_TESTS: ${{ inputs.require-node-tests }}
run: |
if [ "$REQUIRE_TESTS" = true ] && ! jq -e '.scripts.test' package.json >/dev/null; then
echo "::error::package.json has no test script. Add one, or set require-node-tests to false."
exit 1
fi
IFS=',' read -r -a scripts <<<"$SCRIPTS"
for script in "${scripts[@]}"; do
script=$(echo "$script" | xargs)
[ -n "$script" ] || continue
if jq -e --arg s "$script" '.scripts[$s]' package.json >/dev/null; then
echo "::group::$PM run $script"
"$PM" run "$script"
echo "::endgroup::"
else
echo "::notice::No '$script' script, skipping."
fi
done
- name: Report the failure on the PR
if: failure()
env:
GH_TOKEN: ${{ github.token }}
PR: ${{ needs.renovate.outputs.pr-number }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: |
gh pr comment "$PR" --repo "$GITHUB_REPOSITORY" \
--body "The dependency update failed its tests, so it was not merged. See $RUN_URL. Renovate retries on the next run."
merge:
name: Merge the update
needs: [renovate, test]
if: needs.test.result == 'success'
runs-on: ${{ fromJSON(startsWith(inputs.runner || vars.SHARED_ACTIONS_RUNNER || 'ubuntu-latest', '{') && (inputs.runner || vars.SHARED_ACTIONS_RUNNER || 'ubuntu-latest') || format('"{0}"', inputs.runner || vars.SHARED_ACTIONS_RUNNER || 'ubuntu-latest')) }}
timeout-minutes: 10
permissions:
actions: write
contents: write
pull-requests: write
steps:
- name: Squash-merge the PR
env:
# A push made with github.token does not trigger workflows, so the release is dispatched below.
GH_TOKEN: ${{ github.token }}
PR: ${{ needs.renovate.outputs.pr-number }}
HEAD_SHA: ${{ needs.test.outputs.head-sha }}
SUBJECT: ${{ inputs.commit-subject }}
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 != ''
env:
GH_TOKEN: ${{ github.token }}
RELEASE_WORKFLOW: ${{ inputs.release-workflow }}
run: |
branch=$(gh api "repos/$GITHUB_REPOSITORY" --jq .default_branch)
gh workflow run "$RELEASE_WORKFLOW" --repo "$GITHUB_REPOSITORY" --ref "$branch"
+115 -1
View File
@@ -4,6 +4,21 @@ Reusable workflows and composite actions for Go projects.
## Reusable Workflows
### Runner selection
Every reusable workflow accepts `runner` (label, or a JSON object to pick a runner group):
```yaml
with:
runner: self-hosted
# or
runner: '{"group":"ci-linux","labels":["x64"]}'
```
Resolution order: `inputs.runner`, repository/org variable `SHARED_ACTIONS_RUNNER`, `ubuntu-latest`.
Set the variable once to move every consumer. In `go-release-cgo.yaml` the build matrix keeps its
per-platform `os`; `runner` covers the other jobs. Runner groups exist only for organisations and enterprises.
### `go-release.yaml`
Standard Go release workflow using GoReleaser.
@@ -57,6 +72,11 @@ jobs:
Pull request checks: tests, linting, security scans.
- `go vet` / `staticcheck` findings show as inline annotations on the PR diff; gosec and CodeQL post to code scanning.
- The `report` job posts one sticky comment with every check's result and coverage, and fails if any check fails or coverage is below `coverage-threshold`. Require `PR Checks Report` in branch protection.
- A push to a branch with an open PR skips the duplicate run; stale runs on the same branch are cancelled.
- Fork PRs cannot receive the comment; the report still lands in the job summary.
```yaml
jobs:
pr-checks:
@@ -68,7 +88,7 @@ jobs:
### `go-autoupdate.yaml`
Automatic dependency updates.
Automatic dependency updates for Go. See `renovate-autoupdate.yaml` for the Renovate-based replacement that also covers npm, pnpm and yarn.
```yaml
jobs:
@@ -80,6 +100,75 @@ jobs:
secrets: inherit
```
### `renovate-autoupdate.yaml`
Daily dependency updates for Go and npm/pnpm/yarn projects, built on Renovate. It replaces `go-autoupdate.yaml`.
1. Renovate puts every minor and patch update (direct and indirect Go modules, npm, pnpm and yarn packages) in one PR on the branch `deps-autoupdate/batch`.
2. The workflow checks out that branch and runs the tests: `go build`, `go test -race -cover` for Go, and the `package.json` scripts for Node.
3. If everything passes, it squash-merges the PR. If a test fails, the PR stays open with a comment and Renovate retries on the next run.
```yaml
name: Update dependencies
on:
workflow_dispatch:
schedule:
- cron: "0 3 * * *"
permissions:
actions: write # only to dispatch release-workflow
contents: write
pull-requests: write
jobs:
update:
uses: lukaszraczylo/shared-actions/.github/workflows/renovate-autoupdate.yaml@main
with:
go-version: ">=1.24"
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.
**Inputs:**
| Input | Default | Description |
|-------|---------|-------------|
| `go-version` | `>=1.24` | Go version |
| `go-working-directory` | `.` | Directory that holds `go.mod` |
| `node-version` | `22` | Node.js version |
| `node-working-directory` | `.` | Directory that holds `package.json` |
| `node-scripts` | `lint,typecheck,test,build` | Scripts to run in order. A missing script is skipped. |
| `require-node-tests` | `true` | Fail when there is no `test` script, so an untested update never merges |
| `include-major` | `false` | Put major updates in the batch. When `false`, each major gets its own PR that is never merged automatically. |
| `minimum-release-age` | `2 days` | Skip releases younger than this |
| `renovate-version` | `44` | Renovate version |
| `commit-subject` | `chore(deps): update dependencies` | Squash commit subject |
| `release-workflow` | | Workflow file to dispatch after the merge, for example `release.yaml`. It needs a `workflow_dispatch` trigger. Empty means no dispatch. |
| `admin-merge` | `false` | Merge with `--admin`. The workflow token cannot bypass branch protection. |
| `lfs` | `false` | Git LFS checkout |
| `runner` | | Runner label or runner group, as in the other workflows |
**Package manager.** pnpm is used when `pnpm-lock.yaml` exists, yarn for `yarn.lock`, otherwise npm. The install step never changes the lockfile (`--frozen-lockfile`, `--immutable`, `npm ci`), so a lockfile that does not match `package.json` fails the run. pnpm and yarn run through Corepack, so set `packageManager` in `package.json`.
**Per-project rules.** Put a `renovate.json` in the calling repository. Renovate reads it and merges it with the defaults, so no extra input is needed:
```json
{
"ignoreDeps": ["some/package"],
"packageRules": [{ "matchPackageNames": ["vite"], "enabled": false }]
}
```
The defaults already cap `typescript` below 7, which breaks `vue-tsc`.
**Token.** 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`.
**Commit message.** The squash commit uses a fixed subject and body without the words `major`, `minor` or `breaking`, because `semver-generator` matches release keywords in commit messages.
## Composite Actions
### `actions/go-test`
@@ -134,6 +223,31 @@ Run GoReleaser with mode support.
github-token: ${{ secrets.GITHUB_TOKEN }}
```
### `actions/preflight`
Fail in seconds when a prerequisite is missing, instead of at the end of a long run. Reads names from the step `env`, so map secrets there.
```yaml
- uses: lukaszraczylo/shared-actions/.github/actions/preflight@main
with:
require: GITHUB_TOKEN,NPM_TOKEN # must be non-empty
repo-access: lukaszraczylo/helm-charts # token needs push access
repo-token-env: HOMEBREW_TAP_TOKEN
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
HOMEBREW_TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }}
```
Optional probes use the real token, so they catch caller `permissions:` that are too narrow:
- `release-write: "true"` runs `git push --dry-run`, which fails when the token cannot push (no tag or release possible).
- `ghcr-push: true|auto` asks ghcr.io for a push token and opens a blob upload session (no package is created). `auto` probes only when the config has `dockers`/`dockers_v2`/`docker_manifests` and mentions `ghcr.io`. The image is the first `ghcr.io/...` path in the config, else the repository (`ghcr-image` overrides).
- A probe that cannot reach a verdict (network error, odd status) warns and does not fail.
It also scans `.goreleaser.y*ml` and requires every `{{ .Env.NAME }}` (comment lines ignored) to be set. All problems are listed in one run.
`go-release.yaml` and `go-release-cgo.yaml` run it in a first `preflight` job before tests. Set `preflight: false` to skip it, and `preflight-repos` to check access to a tap or helm-charts repo. An empty secret usually means the caller omitted `secrets: inherit`.
### `actions/rolling-release`
Create/update a rolling release tag.