Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .github/actions/kit-retry/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
name: Kit retry
description: Retry download commands with a linear delay
runs:
using: composite
steps:
- shell: bash
env:
KIT_RETRY_PATH: ${{ github.action_path }}
run: echo "$KIT_RETRY_PATH" >> "$GITHUB_PATH"
13 changes: 13 additions & 0 deletions .github/actions/kit-retry/kit-download
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
#!/usr/bin/env bash
set -eu

if [ "$#" -ne 2 ] || [ -d "$2" ]; then
echo 'usage: kit-download URL DEST' >&2
exit 2
fi
URL=$1
DEST=$2
tmp="$DEST.part.$$"
trap 'rm -f "$tmp"' EXIT
"$(dirname -- "${BASH_SOURCE[0]}")/kit-retry" 3 5 curl --silent --show-error --fail --location --connect-timeout 30 --speed-limit 1 --speed-time 60 -o "$tmp" "$URL"
mv -f "$tmp" "$DEST"
31 changes: 31 additions & 0 deletions .github/actions/kit-retry/kit-retry
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
#!/usr/bin/env bash
set -u

usage() {
echo 'usage: kit-retry ATTEMPTS BASE_DELAY COMMAND [ARG...]' >&2
exit 2
}

if [ "$#" -lt 3 ] || ! [[ "$1" =~ ^[1-9][0-9]*$ ]] || \
! [[ "$2" =~ ^[0-9]+$ ]]; then
usage
fi
max_attempts=$((10#$1))
base_delay=$((10#$2))
shift 2

attempt=1
while true; do
( "$@" )
status=$?
if [ "$status" -eq 0 ]; then
exit 0
fi
if [ "$attempt" -ge "$max_attempts" ]; then
exit "$status"
fi
delay=$((base_delay * attempt))
printf 'command failed with status %s; retrying in %s seconds\n' "$status" "$delay" >&2
sleep "$delay" || exit "$?"
attempt=$((attempt + 1))
done
11 changes: 10 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,11 @@ jobs:
go-version-file: go.mod
cache: true

- uses: ./.github/actions/kit-retry

- name: Download modules
run: kit-retry 3 5 go mod download

- name: Check go.mod is tidy
run: |
go mod tidy
Expand Down Expand Up @@ -101,8 +106,11 @@ jobs:
go-version-file: go.mod
cache: true

- uses: ./.github/actions/kit-retry

- name: Download modules
run: go mod download
shell: bash
run: kit-retry 3 5 go mod download

- name: Build
if: matrix.shard == 'regular'
Expand Down Expand Up @@ -230,6 +238,7 @@ jobs:
endpoint="http://$(docker port kit-s3-conformance 9000/tcp)"
echo "KIT_S3_TEST_ENDPOINT=${endpoint}" >> "${GITHUB_ENV}"
for attempt in $(seq 1 30); do
# workflowretry: allow local container readiness already retries
if curl --fail --silent --show-error \
"${endpoint}/health" >/dev/null; then
exit 0
Expand Down
9 changes: 6 additions & 3 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -16,17 +16,17 @@ override GOFLAGS += -trimpath
endif
export GOFLAGS

.PHONY: lint lint-golangci lint-config lint-config-check custom-gcl nilaway
.PHONY: lint lint-golangci lint-config lint-config-check custom-gcl nilaway lint-workflow-retries

# lint is the repository lint gate: shared config drift, golangci-lint with the
# kit analyzers, and nilaway.
lint: lint-config-check lint-golangci nilaway
lint: lint-config-check lint-golangci nilaway lint-workflow-retries

# custom-gcl builds golangci-lint with the plugins in .custom-gcl.yml
# (the kit analyzers from ./lint/gclplugin and nilaway). golangci-lint keeps
# the build cached and skips it when nothing changed.
custom-gcl:
go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@$(GOLANGCI_LINT_VERSION) custom \
.github/actions/kit-retry/kit-retry 3 5 go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@$(GOLANGCI_LINT_VERSION) custom \
--destination $(CUSTOM_GCL_DIR) --name custom-gcl --version $(GOLANGCI_LINT_VERSION)

lint-golangci: custom-gcl
Expand All @@ -47,3 +47,6 @@ lint-config-check:
# Set KIT_EMBED_TEST_DIR and KIT_EMBED_TEST_LIBRARY to include native parity.
test-embedserver:
go test -race ./embedserver ./cmd/kennembed ./internal/embedassets ./embedlocal

lint-workflow-retries:
go run ./cmd/kennlint workflow-retries
32 changes: 28 additions & 4 deletions cmd/kennlint/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
// golangci-lint configuration.
//
// kennlint run [analysis flags] ./...
// kennlint workflow-retries [path ...]
// kennlint sql [path ...]
// kennlint config [-overlay FILE] [-out FILE] [-check]
// kennlint profile [-tags LIST] [-disable LIST] [-parallel] [-cpuprofile FILE] [packages]
Expand All @@ -17,6 +18,7 @@ import (
"io/fs"
"os"
"path/filepath"
"slices"
"strings"

"golang.org/x/tools/go/analysis/multichecker"
Expand All @@ -28,6 +30,7 @@ import (

const usage = `usage:
kennlint run [analysis flags] [packages] run the kit analyzers (go vet style)
kennlint workflow-retries [path ...] report downloads missing retries in workflow YAML (default: .github)
kennlint sql [path ...] report CHECK constraints and enum types in .sql files (default: .)
kennlint config [flags] render .golangci.yml from the canonical config and an overlay
kennlint profile [flags] [packages] time each analyzer, split between packages and dependencies
Expand All @@ -53,6 +56,15 @@ func main() {
if n > 0 {
os.Exit(1)
}
case "workflow-retries":
n, err := runWorkflowRetries(os.Args[2:], os.Stdout)
if err != nil {
fmt.Fprintf(os.Stderr, "kennlint workflow-retries: %v\n", err)
os.Exit(2)
}
if n > 0 {
os.Exit(1)
}
case "config":
if err := runConfig(os.Args[2:], os.Stdout, os.Stderr); err != nil {
fmt.Fprintf(os.Stderr, "kennlint config: %v\n", err)
Expand Down Expand Up @@ -126,6 +138,17 @@ func runSQL(paths []string, stdout io.Writer) (int, error) {
if len(paths) == 0 {
paths = []string{"."}
}
return scanFiles(paths, []string{".sql"}, func(path string, src []byte) (int, error) {
findings := sqlcheck.Scan(string(src))
for _, f := range findings {
fmt.Fprintf(stdout, "%s:%d:%d: %s\n", filepath.ToSlash(path), f.Line, f.Column, f.Message())
}
return len(findings), nil
})
}

// scanFiles shares path selection and error handling for non-Go checks.
func scanFiles(paths, extensions []string, scan func(string, []byte) (int, error)) (int, error) {
total := 0
for _, root := range paths {
err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
Expand All @@ -138,16 +161,17 @@ func runSQL(paths []string, stdout io.Writer) (int, error) {
}
return nil
}
if !strings.EqualFold(filepath.Ext(path), ".sql") {
if !slices.Contains(extensions, strings.ToLower(filepath.Ext(path))) {
return nil
}
src, err := os.ReadFile(path)
if err != nil {
return err
}
for _, f := range sqlcheck.Scan(string(src)) {
total++
fmt.Fprintf(stdout, "%s:%d:%d: %s\n", filepath.ToSlash(path), f.Line, f.Column, f.Message())
n, err := scan(path, src)
total += n
if err != nil {
return fmt.Errorf("%s: %w", filepath.ToSlash(path), err)
}
return nil
})
Expand Down
25 changes: 25 additions & 0 deletions cmd/kennlint/workflow_retry.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package main

import (
"fmt"
"io"
"path/filepath"

"go.kenn.io/kit/lint/workflowretry"
)

func runWorkflowRetries(paths []string, stdout io.Writer) (int, error) {
if len(paths) == 0 {
paths = []string{".github"}
}
return scanFiles(paths, []string{".yml", ".yaml"}, func(path string, src []byte) (int, error) {
findings, err := workflowretry.Scan(src)
if err != nil {
return 0, err
}
for _, f := range findings {
fmt.Fprintf(stdout, "%s:%d:%d: job %s, step %s: %s\n", filepath.ToSlash(path), f.Line, f.Column, f.Job, f.Step, f.Message)
}
return len(findings), nil
})
}
28 changes: 28 additions & 0 deletions cmd/kennlint/workflow_retry_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package main

import (
"bytes"
"os"
"path/filepath"
"strings"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestWorkflowRetries(t *testing.T) {
t.Chdir(t.TempDir())
dir := ".github"
file := filepath.Join(dir, "workflows", "ci.yml")
require.NoError(t, os.MkdirAll(filepath.Dir(file), 0o700))
require.NoError(t, os.WriteFile(file, []byte("jobs:\n test:\n steps:\n - name: Download\n shell: bash\n run: npm ci\n"), 0o600))
var output bytes.Buffer
n, err := runWorkflowRetries(nil, &output)
require.NoError(t, err)
assert.Equal(t, 1, n)
assert.True(t, strings.HasPrefix(output.String(), filepath.ToSlash(file)+":6:14: job test, step Download: npm: wrap in kit-retry\n"))
require.NoError(t, os.WriteFile(file, []byte("jobs: ["), 0o600))
_, err = runWorkflowRetries([]string{dir}, &output)
assert.ErrorContains(t, err, "ci.yml")
}
45 changes: 45 additions & 0 deletions docs/adopting-kennlint.md
Original file line number Diff line number Diff line change
Expand Up @@ -211,3 +211,48 @@ The canonical configuration is `lint/config/golangci.yml`. Analyzers live in
`lint/<name>/` with `analysistest` fixtures under `testdata/`. Changes there
land in every repository on its next kit upgrade and `kennlint config` run, so
prefer additive changes and describe the rationale in the pull request.

## Retrying workflow downloads

`kennlint workflow-retries [path ...]` scans workflow and composite-action YAML,
defaulting to `.github`. It checks Bash and sh `run:` steps. Steps without an
explicit shell are checked as Bash on every runner, even though Windows runs
them in PowerShell. Steps that don't parse as Bash are skipped.
`kit-retry` and `kit-download` only run under Bash, so Windows steps that call
them need `shell: bash`. Set `shell: pwsh` on PowerShell-only steps to skip them.
Every `curl` or `wget` call must use `kit-download`.
Package installers must run under `kit-retry`, including APT, Go, npm, pnpm,
yarn, bun, pip, Docker pulls, apk, brew, cargo, pipx, and dnf.
A bare `yarn`, with only options, counts as an install.
Findings point at the command in `run: |` blocks and single-line plain values,
and at the `run:` key otherwise.
The check does not follow other launchers (`timeout`, `xargs`, `bash -c`, or
value-taking options of `sudo`, `env`, and `exec`), and does not flag unlisted
commands that fetch, such as `docker run`, `go run module@version`, or
`go mod tidy`, or installers outside the list, such as `gem`, `uv`, or `choco`.

Add the action after checkout to put both commands on PATH. Copy the action
into your repository, or reference the Kit action at a pinned commit.

```yaml
- uses: ./.github/actions/kit-retry
- shell: bash
run: |
kit-download https://example.com/tool.tgz tool.tgz
kit-retry 3 5 sudo apt-get -o APT::Update::Error-Mode=any update
kit-retry 3 5 sudo apt-get install -y tool
```

`kit-retry ATTEMPTS BASE_DELAY COMMAND [ARG...]` waits BASE_DELAY seconds,
then twice that delay, between attempts. The example makes three attempts
with waits of five and ten seconds. APT's error mode makes a failed mirror
fetch return an error; plain `apt-get update` can exit successfully.

`kit-download URL DEST` replaces DEST only after a successful download and
removes its temporary file on failure. It retries three times with waits of
five and ten seconds, a 30-second connection timeout, and a low-speed timeout.
For `curl | sh` installers, download a file first, then run it. Downloads with
authentication headers need a custom download command with an allow comment.
Put `# workflowretry: allow <reason>` on the line where the command starts,
or alone on the line above. Local readiness probes that already poll can use it.
On Windows, `shell: bash` is the tested path for the action's PATH setup.
3 changes: 2 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ require (
google.golang.org/api v0.310.0
gopkg.in/yaml.v3 v3.0.1
modernc.org/sqlite v1.61.0
mvdan.cc/sh/v3 v3.14.1
)

require (
Expand Down Expand Up @@ -109,7 +110,7 @@ require (
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-ole/go-ole v1.3.0 // indirect
github.com/goccy/go-json v0.10.6 // indirect
github.com/google/renameio/v2 v2.0.0 // indirect
github.com/google/renameio/v2 v2.0.2 // indirect
github.com/google/s2a-go v0.1.11 // indirect
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect
github.com/google/uuid v1.6.0 // indirect
Expand Down
15 changes: 10 additions & 5 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,8 @@ github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
github.com/go-quicktest/qt v1.102.0 h1:HSQxCeh5YZH3EL3W39ixjtyaEhcWSXQHtHnMBzSs474=
github.com/go-quicktest/qt v1.102.0/go.mod h1:p4lGIVX+8Wa6ZPNDvqcxq36XpUDLh42FLetFU7odllI=
github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU=
github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
Expand Down Expand Up @@ -303,8 +305,8 @@ github.com/google/pprof v0.0.0-20210226084205-cbba55b83ad5/go.mod h1:kpwsk12EmLe
github.com/google/pprof v0.0.0-20261016150608-1facf8f91b50 h1:PlNWJRxu5APSV0pWqfIsZ0qt9OOGHSWfZQ4zvx+WgWs=
github.com/google/pprof v0.0.0-20261016150608-1facf8f91b50/go.mod h1:yUo1AMMpsPIDFGuwDK8nLcf8+vvAd5l2O6JDZ6hpwrM=
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
github.com/google/renameio/v2 v2.0.0 h1:UifI23ZTGY8Tt29JbYFiuyIU3eX+RNFtUwefq9qAhxg=
github.com/google/renameio/v2 v2.0.0/go.mod h1:BtmJXm5YlszgC+TD4HOEEUFgkJP3nLxehU6hfe7jRt4=
github.com/google/renameio/v2 v2.0.2 h1:qKZs+tfn+arruZZhQ7TKC/ergJunuJicWS6gLDt/dGw=
github.com/google/renameio/v2 v2.0.2/go.mod h1:OX+G6WHHpHq3NVj7cAOleLOwJfcQ1s3uUJQCrr78SWo=
github.com/google/s2a-go v0.1.11 h1:7v1V1TraNGP63WptsAkNqr2w8jgm4vM6A6qx6Lsw9iM=
github.com/google/s2a-go v0.1.11/go.mod h1:pz4tyvwXvJLLbyrkh6FW1eS2zPUXMaTmyNhYtyP2tNw=
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4=
Expand Down Expand Up @@ -377,8 +379,9 @@ github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORN
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/leanovate/gopter v0.2.11 h1:vRjThO1EKPb/1NsDXuDrzldR28RLkBflWYcU9CvzWu4=
Expand Down Expand Up @@ -465,8 +468,8 @@ github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ=
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/rogpeppe/go-internal v1.15.0 h1:D0RCU5rMAp+SpgkiNdrjfJ+LX4J1M32V2NeCY7EJ6hc=
github.com/rogpeppe/go-internal v1.15.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs=
github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU=
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
Expand Down Expand Up @@ -1066,6 +1069,8 @@ modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
mvdan.cc/sh/v3 v3.14.1 h1:bXkhQWNHCs0KZEChF8hYS6FC+T2N9mUZLbQv9blditI=
mvdan.cc/sh/v3 v3.14.1/go.mod h1:syYCoFET8w9tvevxiXUtY8/ICrU+l26jHmhJDra3Vwo=
rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8=
rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0=
rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA=
3 changes: 2 additions & 1 deletion lint/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@
rules do not live in the schema.
- `lint/gclplugin` registers the analyzers as the `kennlint` golangci-lint
module plugin.
- `cmd/kennlint` runs the analyzers and renders or checks the configuration.
- `cmd/kennlint` runs analyzers, renders or checks configuration, and scans
workflow downloads with `kennlint workflow-retries`.

See `docs/adopting-kennlint.md` for the consumer workflow.

Expand Down
Loading
Loading