├── .gitignore ├── pkg ├── edit │ ├── testdata │ │ ├── unmarshal_error.yaml │ │ ├── jobs_not_found.yaml │ │ ├── invalid_jobs.yaml │ │ ├── nochange.yaml │ │ ├── reusable_workflow_timeout.yaml │ │ ├── reusable_workflow_timeout_result.yaml │ │ ├── normal.yaml │ │ └── normal_result.yaml │ ├── edit.go │ ├── workflow.go │ ├── ast.go │ └── edit_internal_test.go ├── controller │ └── set │ │ ├── set.go │ │ ├── workflow.go │ │ └── estimate.go ├── cli │ ├── runner.go │ └── set.go └── github │ ├── workflow_run.go │ ├── github.go │ └── workflow_job.go ├── aqua ├── imports │ ├── ghcp.yaml │ ├── syft.yaml │ ├── cmdx.yaml │ ├── cosign.yaml │ ├── actionlint.yaml │ ├── ghatm.yaml │ ├── go-licenses.yaml │ ├── reviewdog.yaml │ ├── goreleser.yaml │ └── golangci-lint.yaml ├── aqua.yaml └── aqua-checksums.json ├── scripts ├── fmt.sh ├── generate-usage.sh └── coverage.sh ├── .editorconfig ├── .github ├── FUNDING.yml └── workflows │ ├── actionlint.yaml │ ├── autofix.yaml │ ├── watch-star.yaml │ ├── wc-renovate-config-validator.yaml │ ├── release.yaml │ ├── check-commit-signing.yaml │ ├── test.yaml │ ├── workflow_call_test.yaml │ └── wc-test.yaml ├── cmd └── ghatm │ └── main.go ├── renovate.json5 ├── testdata ├── before.yaml └── after.yaml ├── go.mod ├── .golangci.yml ├── cmdx.yaml ├── LICENSE ├── .goreleaser.yml ├── USAGE.md ├── go.sum └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | .coverage 2 | third_party_licenses 3 | -------------------------------------------------------------------------------- /pkg/edit/testdata/unmarshal_error.yaml: -------------------------------------------------------------------------------- 1 | "unmarshal error" 2 | -------------------------------------------------------------------------------- /aqua/imports/ghcp.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - name: int128/ghcp@v1.15.1 3 | -------------------------------------------------------------------------------- /aqua/imports/syft.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - name: anchore/syft@v1.38.2 3 | -------------------------------------------------------------------------------- /aqua/imports/cmdx.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - name: suzuki-shunsuke/cmdx@v2.0.2 3 | -------------------------------------------------------------------------------- /aqua/imports/cosign.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - name: sigstore/cosign@v3.0.3 3 | -------------------------------------------------------------------------------- /aqua/imports/actionlint.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - name: rhysd/actionlint@v1.7.9 3 | -------------------------------------------------------------------------------- /aqua/imports/ghatm.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - name: suzuki-shunsuke/ghatm@v1.0.0 3 | -------------------------------------------------------------------------------- /aqua/imports/go-licenses.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - name: google/go-licenses@v2.0.1 3 | -------------------------------------------------------------------------------- /aqua/imports/reviewdog.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - name: reviewdog/reviewdog@v0.21.0 3 | -------------------------------------------------------------------------------- /pkg/edit/testdata/jobs_not_found.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | name: before 3 | on: pull_request 4 | -------------------------------------------------------------------------------- /aqua/imports/goreleser.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - name: goreleaser/goreleaser@v2.13.1 3 | -------------------------------------------------------------------------------- /aqua/imports/golangci-lint.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - name: golangci/golangci-lint@v2.7.2 3 | -------------------------------------------------------------------------------- /pkg/edit/testdata/invalid_jobs.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | name: before 3 | on: pull_request 4 | jobs: 5 | foo: null 6 | -------------------------------------------------------------------------------- /scripts/fmt.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -euo pipefail 4 | 5 | git ls-files | grep -E "\.go$" | xargs gofumpt -w 6 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | end_of_line = lf 5 | insert_final_newline = true 6 | charset = utf-8 7 | 8 | [*.json] 9 | indent_style = space 10 | indent_size = 2 11 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/displaying-a-sponsor-button-in-your-repository 2 | github: 3 | - suzuki-shunsuke 4 | -------------------------------------------------------------------------------- /cmd/ghatm/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/suzuki-shunsuke/ghatm/pkg/cli" 5 | "github.com/suzuki-shunsuke/urfave-cli-v3-util/urfave" 6 | ) 7 | 8 | var version = "" 9 | 10 | func main() { 11 | urfave.Main("ghatm", version, cli.Run) 12 | } 13 | -------------------------------------------------------------------------------- /scripts/generate-usage.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -eu 4 | 5 | cd "$(dirname "$0")/.." 6 | 7 | help=$(ghatm help-all) 8 | 9 | echo "# Usage 10 | 11 | 12 | 13 | $help" > USAGE.md 14 | -------------------------------------------------------------------------------- /aqua/aqua.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | # aqua - Declarative CLI Version Manager 3 | # https://aquaproj.github.io/ 4 | checksum: 5 | enabled: true 6 | require_checksum: true 7 | registries: 8 | - type: standard 9 | ref: v4.446.0 # renovate: depName=aquaproj/aqua-registry 10 | import_dir: imports 11 | -------------------------------------------------------------------------------- /.github/workflows/actionlint.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | name: actionlint 3 | on: pull_request 4 | jobs: 5 | actionlint: 6 | runs-on: ubuntu-24.04 7 | timeout-minutes: 10 8 | permissions: 9 | contents: read 10 | pull-requests: write 11 | steps: 12 | - uses: suzuki-shunsuke/actionlint-action@29e0b7cda52e51a495d15f22759745ef6e19583a # v0.1.1 13 | -------------------------------------------------------------------------------- /.github/workflows/autofix.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | name: autofix.ci 3 | on: pull_request 4 | permissions: {} 5 | jobs: 6 | autofix: 7 | runs-on: ubuntu-24.04 8 | permissions: {} 9 | timeout-minutes: 15 10 | steps: 11 | - uses: suzuki-shunsuke/go-autofix-action@2a159c8f21af356c29ee26d76821b5c15956c1d6 # v0.1.11 12 | with: 13 | aqua_version: v2.55.3 14 | -------------------------------------------------------------------------------- /.github/workflows/watch-star.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | name: watch-star 3 | on: 4 | watch: 5 | types: 6 | - started 7 | jobs: 8 | watch-star: 9 | timeout-minutes: 30 10 | runs-on: ubuntu-latest 11 | permissions: 12 | issues: write 13 | steps: 14 | - uses: suzuki-shunsuke/watch-star-action@2b3d259ce2ea06d53270dfe33a66d5642c8010ca # v0.1.1 15 | with: 16 | number: 1 17 | -------------------------------------------------------------------------------- /.github/workflows/wc-renovate-config-validator.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | name: renovate-config-validator 3 | on: workflow_call 4 | jobs: 5 | renovate-config-validator: 6 | # Validate Renovate Configuration by renovate-config-validator. 7 | uses: suzuki-shunsuke/renovate-config-validator-workflow/.github/workflows/validate.yaml@dcf025732ed76061838048f7f8b0099ae0e89876 # v0.2.5 8 | permissions: 9 | contents: read 10 | -------------------------------------------------------------------------------- /pkg/edit/testdata/nochange.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | name: before 3 | on: pull_request 4 | jobs: 5 | actionlint: 6 | uses: suzuki-shunsuke/actionlint-workflow/.github/workflows/actionlint.yaml@813a6d08c08cfd7a08618a89a59bfe78e573597c # v1.0.1 7 | 8 | foo: 9 | runs-on: ubuntu-latest 10 | timeout-minutes: 5 11 | steps: 12 | - uses: actions/checkout@v2 13 | 14 | bar: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/cache@v2 18 | timeout-minutes: 5 19 | -------------------------------------------------------------------------------- /renovate.json5: -------------------------------------------------------------------------------- 1 | { 2 | extends: [ 3 | "github>aquaproj/aqua-renovate-config#2.9.0", 4 | "github>aquaproj/aqua-renovate-config:file#2.9.0(aqua/imports/.*\\.ya?ml)", 5 | "github>lintnet/renovate-config#0.1.2", 6 | "github>suzuki-shunsuke/renovate-config#3.3.1", 7 | "github>suzuki-shunsuke/renovate-config:action-go-version#3.3.1", 8 | "github>suzuki-shunsuke/renovate-config:nolimit#3.3.1", 9 | "github>suzuki-shunsuke/renovate-config:go-directive#3.3.1", 10 | ], 11 | } 12 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Release 3 | on: 4 | push: 5 | tags: [v*] 6 | permissions: {} 7 | jobs: 8 | release: 9 | uses: suzuki-shunsuke/go-release-workflow/.github/workflows/release.yaml@4db55f2aeaca166b2655fa4a80658df1050dc203 # v7.0.0 10 | with: 11 | go-version-file: go.mod 12 | aqua_policy_allow: true 13 | aqua_version: v2.55.3 14 | permissions: 15 | contents: write 16 | id-token: write 17 | actions: read 18 | attestations: write 19 | -------------------------------------------------------------------------------- /.github/workflows/check-commit-signing.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Check if all commits are signed 3 | on: 4 | pull_request_target: 5 | branches: [main] 6 | concurrency: 7 | group: ${{ github.workflow }}--${{ github.head_ref }} # github.ref is unavailable in case of pull_request_target 8 | cancel-in-progress: true 9 | jobs: 10 | check-commit-signing: 11 | uses: suzuki-shunsuke/check-commit-signing-workflow/.github/workflows/check.yaml@547eee345f56310a656f271ec5eaa900af46b0fb # v0.1.0 12 | permissions: 13 | contents: read 14 | pull-requests: write 15 | -------------------------------------------------------------------------------- /.github/workflows/test.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | name: test 3 | on: pull_request 4 | concurrency: 5 | group: ${{ github.workflow }}-${{ github.ref }} 6 | cancel-in-progress: true 7 | permissions: {} 8 | jobs: 9 | test: 10 | uses: ./.github/workflows/workflow_call_test.yaml 11 | permissions: 12 | contents: read 13 | status-check: 14 | runs-on: ubuntu-24.04 15 | if: always() && (contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled')) 16 | timeout-minutes: 10 17 | permissions: {} 18 | needs: 19 | - test 20 | steps: 21 | - run: exit 1 22 | -------------------------------------------------------------------------------- /scripts/coverage.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -eu 4 | set -o pipefail 5 | 6 | cd "$(dirname "$0")/.." 7 | 8 | if [ $# -eq 0 ]; then 9 | target="$(go list ./... | fzf)" 10 | profile=.coverage/$target/coverage.txt 11 | mkdir -p .coverage/"$target" 12 | elif [ $# -eq 1 ]; then 13 | target=$1 14 | mkdir -p .coverage/"$target" 15 | profile=.coverage/$target/coverage.txt 16 | target=./$target 17 | else 18 | echo "too many arguments are given: $*" >&2 19 | exit 1 20 | fi 21 | 22 | go test "$target" -coverprofile="$profile" -covermode=atomic 23 | go tool cover -html="$profile" 24 | -------------------------------------------------------------------------------- /pkg/edit/testdata/reusable_workflow_timeout.yaml: -------------------------------------------------------------------------------- 1 | name: Test for reusable workflow timeout 2 | on: 3 | workflow_call: 4 | inputs: 5 | timeout: 6 | required: false 7 | type: number 8 | default: 2 9 | jobs: 10 | with-timeout: 11 | timeout-minutes: ${{ inputs.timeout }} 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Wait 15 | shell: bash 16 | run: | 17 | for i in {1..180}; do 18 | echo "${i}" 19 | sleep 1 20 | done 21 | without-timeout: 22 | runs-on: ubuntu-latest 23 | steps: 24 | - uses: actions/checkout@v3 25 | -------------------------------------------------------------------------------- /pkg/edit/testdata/reusable_workflow_timeout_result.yaml: -------------------------------------------------------------------------------- 1 | name: Test for reusable workflow timeout 2 | on: 3 | workflow_call: 4 | inputs: 5 | timeout: 6 | required: false 7 | type: number 8 | default: 2 9 | jobs: 10 | with-timeout: 11 | timeout-minutes: ${{ inputs.timeout }} 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Wait 15 | shell: bash 16 | run: | 17 | for i in {1..180}; do 18 | echo "${i}" 19 | sleep 1 20 | done 21 | without-timeout: 22 | timeout-minutes: 30 23 | runs-on: ubuntu-latest 24 | steps: 25 | - uses: actions/checkout@v3 26 | -------------------------------------------------------------------------------- /testdata/before.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | name: before 3 | on: pull_request 4 | jobs: 5 | actionlint: 6 | uses: suzuki-shunsuke/actionlint-workflow/.github/workflows/actionlint.yaml@813a6d08c08cfd7a08618a89a59bfe78e573597c # v1.0.1 7 | 8 | foo: 9 | runs-on: ubuntu-latest 10 | timeout-minutes: 5 11 | steps: 12 | - uses: actions/checkout@v2 13 | 14 | bar: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/cache@v2 18 | timeout-minutes: 5 19 | 20 | zoo: 21 | runs-on: ubuntu-latest 22 | steps: 23 | - uses: actions/checkout@v2 24 | 25 | yoo: 26 | runs-on: ubuntu-latest 27 | steps: 28 | - uses: actions/checkout@v3 29 | -------------------------------------------------------------------------------- /pkg/edit/testdata/normal.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | name: before 3 | on: pull_request 4 | jobs: 5 | actionlint: 6 | uses: suzuki-shunsuke/actionlint-workflow/.github/workflows/actionlint.yaml@813a6d08c08cfd7a08618a89a59bfe78e573597c # v1.0.1 7 | 8 | foo: 9 | runs-on: ubuntu-latest 10 | timeout-minutes: 5 11 | steps: 12 | - uses: actions/checkout@v2 13 | 14 | bar: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/cache@v2 18 | timeout-minutes: 5 19 | 20 | zoo: 21 | runs-on: ubuntu-latest 22 | steps: 23 | - uses: actions/checkout@v2 24 | 25 | yoo: 26 | runs-on: ubuntu-latest 27 | steps: 28 | - uses: actions/checkout@v3 29 | -------------------------------------------------------------------------------- /testdata/after.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | name: before 3 | on: pull_request 4 | jobs: 5 | actionlint: 6 | uses: suzuki-shunsuke/actionlint-workflow/.github/workflows/actionlint.yaml@813a6d08c08cfd7a08618a89a59bfe78e573597c # v1.0.1 7 | 8 | foo: 9 | runs-on: ubuntu-latest 10 | timeout-minutes: 5 11 | steps: 12 | - uses: actions/checkout@v2 13 | 14 | bar: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/cache@v2 18 | timeout-minutes: 5 19 | 20 | zoo: 21 | timeout-minutes: 30 22 | runs-on: ubuntu-latest 23 | steps: 24 | - uses: actions/checkout@v2 25 | 26 | yoo: 27 | timeout-minutes: 30 28 | runs-on: ubuntu-latest 29 | steps: 30 | - uses: actions/checkout@v3 31 | -------------------------------------------------------------------------------- /pkg/edit/testdata/normal_result.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | name: before 3 | on: pull_request 4 | jobs: 5 | actionlint: 6 | uses: suzuki-shunsuke/actionlint-workflow/.github/workflows/actionlint.yaml@813a6d08c08cfd7a08618a89a59bfe78e573597c # v1.0.1 7 | 8 | foo: 9 | runs-on: ubuntu-latest 10 | timeout-minutes: 5 11 | steps: 12 | - uses: actions/checkout@v2 13 | 14 | bar: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/cache@v2 18 | timeout-minutes: 5 19 | 20 | zoo: 21 | timeout-minutes: 30 22 | runs-on: ubuntu-latest 23 | steps: 24 | - uses: actions/checkout@v2 25 | 26 | yoo: 27 | timeout-minutes: 30 28 | runs-on: ubuntu-latest 29 | steps: 30 | - uses: actions/checkout@v3 31 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/suzuki-shunsuke/ghatm 2 | 3 | go 1.25.5 4 | 5 | require ( 6 | github.com/goccy/go-yaml v1.19.1 7 | github.com/google/go-cmp v0.7.0 8 | github.com/google/go-github/v80 v80.0.0 9 | github.com/spf13/afero v1.15.0 10 | github.com/suzuki-shunsuke/slog-error v0.2.1 11 | github.com/suzuki-shunsuke/slog-util v0.3.0 12 | github.com/suzuki-shunsuke/urfave-cli-v3-util v0.2.0 13 | github.com/urfave/cli/v3 v3.6.1 14 | golang.org/x/oauth2 v0.34.0 15 | gopkg.in/yaml.v3 v3.0.1 16 | ) 17 | 18 | require ( 19 | github.com/google/go-querystring v1.1.0 // indirect 20 | github.com/lmittmann/tint v1.1.2 // indirect 21 | github.com/mattn/go-colorable v0.1.14 // indirect 22 | github.com/mattn/go-isatty v0.0.20 // indirect 23 | github.com/suzuki-shunsuke/go-error-with-exit-code v1.0.0 // indirect 24 | golang.org/x/sys v0.36.0 // indirect 25 | golang.org/x/text v0.28.0 // indirect 26 | ) 27 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | version: "2" 2 | linters: 3 | default: all 4 | disable: 5 | - depguard 6 | - err113 7 | - exhaustive 8 | - exhaustruct 9 | - godot 10 | - godox 11 | - gomoddirectives 12 | - ireturn 13 | - lll 14 | - musttag 15 | - nlreturn 16 | - nonamedreturns 17 | - tagalign 18 | - tagliatelle 19 | - varnamelen 20 | - wsl 21 | - wsl_v5 22 | - noinlineerr 23 | exclusions: 24 | generated: lax 25 | presets: 26 | - comments 27 | - common-false-positives 28 | - legacy 29 | - std-error-handling 30 | paths: 31 | - third_party$ 32 | - builtin$ 33 | - examples$ 34 | formatters: 35 | enable: 36 | - gci 37 | - gofmt 38 | - gofumpt 39 | - goimports 40 | exclusions: 41 | generated: lax 42 | paths: 43 | - third_party$ 44 | - builtin$ 45 | - examples$ 46 | -------------------------------------------------------------------------------- /cmdx.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | # the configuration file of cmdx - task runner 3 | # https://github.com/suzuki-shunsuke/cmdx 4 | tasks: 5 | - name: test 6 | short: t 7 | description: test 8 | usage: test 9 | script: go test ./... -race -covermode=atomic 10 | - name: vet 11 | short: v 12 | description: go vet 13 | usage: go vet 14 | script: go vet ./... 15 | - name: lint 16 | short: l 17 | description: lint the go code 18 | usage: lint the go code 19 | script: golangci-lint run 20 | - name: coverage 21 | short: c 22 | description: coverage test 23 | usage: coverage test 24 | script: "bash scripts/coverage.sh {{.target}}" 25 | args: 26 | - name: target 27 | - name: install 28 | short: i 29 | description: Build and install ghatm 30 | usage: Build and install ghatm by "go install" command 31 | script: go install ./cmd/ghatm 32 | - name: fmt 33 | description: Format GO codes 34 | usage: Format GO codes 35 | script: bash scripts/fmt.sh 36 | -------------------------------------------------------------------------------- /pkg/controller/set/set.go: -------------------------------------------------------------------------------- 1 | package set 2 | 3 | import ( 4 | "context" 5 | "log/slog" 6 | 7 | "github.com/spf13/afero" 8 | "github.com/suzuki-shunsuke/ghatm/pkg/github" 9 | "github.com/suzuki-shunsuke/slog-error/slogerr" 10 | ) 11 | 12 | type Param struct { 13 | Files []string 14 | TimeoutMinutes int 15 | Auto bool 16 | RepoOwner string 17 | RepoName string 18 | Size int 19 | } 20 | 21 | func Set(ctx context.Context, logger *slog.Logger, fs afero.Fs, param *Param) error { 22 | files := param.Files 23 | if len(files) == 0 { 24 | a, err := findWorkflows(fs) 25 | if err != nil { 26 | return err 27 | } 28 | files = a 29 | } 30 | 31 | var gh *github.Client 32 | if param.Auto { 33 | gh = github.NewClient(ctx) 34 | } 35 | 36 | for _, file := range files { 37 | logger := logger.With("workflow_file", file) 38 | logger.Info("handling the workflow file") 39 | if err := handleWorkflow(ctx, logger, fs, gh, file, param); err != nil { 40 | return slogerr.With(err, "workflow_file", file) //nolint:wrapcheck 41 | } 42 | } 43 | return nil 44 | } 45 | -------------------------------------------------------------------------------- /.github/workflows/workflow_call_test.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | name: test (workflow_call) 3 | on: workflow_call 4 | permissions: {} 5 | jobs: 6 | path-filter: 7 | # Get changed files to filter jobs 8 | outputs: 9 | renovate-config-validator: ${{steps.changes.outputs.renovate-config-validator}} 10 | runs-on: ubuntu-latest 11 | timeout-minutes: 10 12 | permissions: {} 13 | steps: 14 | - uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2 15 | id: changes 16 | with: 17 | filters: | 18 | renovate-config-validator: 19 | - renovate.json5 20 | - .github/workflows/test.yaml 21 | - .github/workflows/wc-renovate-config-validator.yaml 22 | 23 | renovate-config-validator: 24 | uses: ./.github/workflows/wc-renovate-config-validator.yaml 25 | needs: path-filter 26 | if: needs.path-filter.outputs.renovate-config-validator == 'true' 27 | permissions: 28 | contents: read 29 | 30 | test: 31 | uses: ./.github/workflows/wc-test.yaml 32 | needs: path-filter 33 | permissions: {} 34 | -------------------------------------------------------------------------------- /pkg/cli/runner.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/suzuki-shunsuke/slog-util/slogutil" 7 | "github.com/suzuki-shunsuke/urfave-cli-v3-util/urfave" 8 | "github.com/urfave/cli/v3" 9 | ) 10 | 11 | type GlobalFlags struct { 12 | LogLevel string 13 | LogColor string 14 | } 15 | 16 | func Run(ctx context.Context, logger *slogutil.Logger, env *urfave.Env) error { 17 | globalFlags := &GlobalFlags{} 18 | return urfave.Command(env, &cli.Command{ //nolint:wrapcheck 19 | Name: "ghatm", 20 | Usage: "Set timeout-minutes to all GitHub Actions jobs. https://github.com/suzuki-shunsuke/ghatm", 21 | Flags: []cli.Flag{ 22 | &cli.StringFlag{ 23 | Name: "log-level", 24 | Usage: "log level", 25 | Destination: &globalFlags.LogLevel, 26 | }, 27 | &cli.StringFlag{ 28 | Name: "log-color", 29 | Usage: "Log color. One of 'auto', 'always' (default), 'never'", 30 | Destination: &globalFlags.LogColor, 31 | }, 32 | }, 33 | Commands: []*cli.Command{ 34 | (&setCommand{}).command(logger, globalFlags), 35 | }, 36 | }).Run(ctx, env.Args) 37 | } 38 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Shunsuke Suzuki 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.github/workflows/wc-test.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | name: wc-test 3 | on: 4 | workflow_call: 5 | inputs: 6 | docker_is_changed: 7 | required: false 8 | type: boolean 9 | 10 | jobs: 11 | test: 12 | timeout-minutes: 30 13 | runs-on: ubuntu-latest 14 | permissions: {} 15 | steps: 16 | - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 17 | with: 18 | persist-credentials: false 19 | - uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c # v6.1.0 20 | with: 21 | go-version-file: go.mod 22 | cache: true 23 | - uses: aquaproj/aqua-installer@11dd79b4e498d471a9385aa9fb7f62bb5f52a73c # v4.0.4 24 | with: 25 | aqua_version: v2.55.3 26 | - run: golangci-lint run --timeout 120s 27 | env: 28 | AQUA_GITHUB_TOKEN: ${{github.token}} 29 | - run: go test -v ./... -race -covermode=atomic 30 | - run: go run ./cmd/ghatm set testdata/before.yaml 31 | - run: diff testdata/before.yaml testdata/after.yaml 32 | - run: go run ./cmd/ghatm set -auto 33 | env: 34 | GITHUB_TOKEN: ${{github.token}} 35 | - run: git diff --exit-code .github/workflows/test.yaml 36 | -------------------------------------------------------------------------------- /pkg/github/workflow_run.go: -------------------------------------------------------------------------------- 1 | package github 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/google/go-github/v80/github" 7 | ) 8 | 9 | type ListWorkflowRunsOptions struct { 10 | Status string 11 | Page int 12 | } 13 | 14 | type WorkflowRun struct { 15 | ID int64 16 | // Can be one of: completed, action_required, cancelled, failure, neutral, skipped, stale, success, timed_out, in_progress, queued, requested, waiting, pending 17 | Status string 18 | } 19 | 20 | func (c *Client) ListWorkflowRuns(ctx context.Context, owner, repo, workflowFileName string, opts *ListWorkflowRunsOptions) ([]*WorkflowRun, *github.Response, error) { 21 | o := &github.ListWorkflowRunsOptions{ 22 | ListOptions: github.ListOptions{ 23 | PerPage: 100, //nolint:mnd 24 | Page: opts.Page, 25 | }, 26 | Status: opts.Status, 27 | } 28 | runs, resp, err := c.actions.ListWorkflowRunsByFileName(ctx, owner, repo, workflowFileName, o) 29 | if err != nil { 30 | return nil, resp, err //nolint:wrapcheck 31 | } 32 | ret := make([]*WorkflowRun, 0, len(runs.WorkflowRuns)) 33 | for _, run := range runs.WorkflowRuns { 34 | ret = append(ret, &WorkflowRun{ 35 | ID: run.GetID(), 36 | Status: run.GetStatus(), 37 | }) 38 | } 39 | return ret, resp, nil 40 | } 41 | -------------------------------------------------------------------------------- /pkg/github/github.go: -------------------------------------------------------------------------------- 1 | package github 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | "os" 7 | 8 | "github.com/google/go-github/v80/github" 9 | "golang.org/x/oauth2" 10 | ) 11 | 12 | type Response = github.Response 13 | 14 | func newGitHub(ctx context.Context) *github.Client { 15 | return github.NewClient(getHTTPClientForGitHub(ctx, getGitHubToken())) 16 | } 17 | 18 | func getGitHubToken() string { 19 | if token := os.Getenv("GHATM_GITHUB_TOKEN"); token != "" { 20 | return token 21 | } 22 | return os.Getenv("GITHUB_TOKEN") 23 | } 24 | 25 | func getHTTPClientForGitHub(ctx context.Context, token string) *http.Client { 26 | if token == "" { 27 | return http.DefaultClient 28 | } 29 | return oauth2.NewClient(ctx, oauth2.StaticTokenSource( 30 | &oauth2.Token{AccessToken: token}, 31 | )) 32 | } 33 | 34 | type ActionsService interface { 35 | ListWorkflowRunsByFileName(ctx context.Context, owner, repo, workflowFileName string, opts *github.ListWorkflowRunsOptions) (*github.WorkflowRuns, *github.Response, error) 36 | ListWorkflowJobs(ctx context.Context, owner, repo string, runID int64, opts *github.ListWorkflowJobsOptions) (*github.Jobs, *github.Response, error) 37 | } 38 | 39 | type Client struct { 40 | actions ActionsService 41 | } 42 | 43 | func NewClient(ctx context.Context) *Client { 44 | return &Client{ 45 | actions: newGitHub(ctx).Actions, 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /pkg/github/workflow_job.go: -------------------------------------------------------------------------------- 1 | package github 2 | 3 | import ( 4 | "context" 5 | "log/slog" 6 | "time" 7 | 8 | "github.com/google/go-github/v80/github" 9 | ) 10 | 11 | type ListWorkflowJobsOptions struct { 12 | Date int 13 | Status string 14 | Page int 15 | } 16 | 17 | type WorkflowJob struct { 18 | ID int64 19 | Name string 20 | // The phase of the lifecycle that the job is currently in. 21 | // "queued", "in_progress", "completed", "waiting", "requested", "pending" 22 | Status string 23 | // The outcome of the job. 24 | // "success", "failure", "neutral", "cancelled", "skipped", "timed_out", "action_required", 25 | Conclusion string 26 | Duration time.Duration 27 | } 28 | 29 | func (c *Client) ListWorkflowJobs(ctx context.Context, logger *slog.Logger, owner, repo string, runID int64, opts *ListWorkflowJobsOptions) ([]*WorkflowJob, *github.Response, error) { 30 | o := &github.ListWorkflowJobsOptions{ 31 | ListOptions: github.ListOptions{ 32 | PerPage: 100, //nolint:mnd 33 | Page: opts.Page, 34 | }, 35 | } 36 | jobs, resp, err := c.actions.ListWorkflowJobs(ctx, owner, repo, runID, o) 37 | if err != nil { 38 | return nil, resp, err //nolint:wrapcheck 39 | } 40 | ret := make([]*WorkflowJob, 0, len(jobs.Jobs)) 41 | for _, job := range jobs.Jobs { 42 | s := job.GetStartedAt() 43 | started := s.GetTime() 44 | if started == nil { 45 | continue 46 | } 47 | j := &WorkflowJob{ 48 | ID: job.GetID(), 49 | Name: job.GetName(), 50 | Status: job.GetStatus(), 51 | Conclusion: job.GetConclusion(), 52 | Duration: job.GetCompletedAt().Sub(*started), 53 | } 54 | if j.Status != "completed" || j.Conclusion != "success" { 55 | logger.Debug("skip the job", "status", j.Status, "conclusion", j.Conclusion) 56 | continue 57 | } 58 | ret = append(ret, j) 59 | } 60 | return ret, resp, nil 61 | } 62 | -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | --- 2 | version: 2 3 | project_name: ghatm 4 | 5 | archives: 6 | - name_template: "{{.ProjectName}}_{{.Os}}_{{.Arch}}" 7 | format_overrides: 8 | - goos: windows 9 | formats: [zip] 10 | files: 11 | - LICENSE 12 | - README.md 13 | - third_party_licenses/**/* 14 | 15 | builds: 16 | - binary: ghatm 17 | main: cmd/ghatm/main.go 18 | env: 19 | - CGO_ENABLED=0 20 | goos: 21 | - windows 22 | - darwin 23 | - linux 24 | goarch: 25 | - amd64 26 | - arm64 27 | 28 | release: 29 | prerelease: "true" 30 | header: | 31 | [Pull Requests](https://github.com/suzuki-shunsuke/ghatm/pulls?q=is%3Apr+milestone%3A{{.Tag}}) | [Issues](https://github.com/suzuki-shunsuke/ghatm/issues?q=is%3Aissue+milestone%3A{{.Tag}}) | https://github.com/suzuki-shunsuke/ghatm/compare/{{.PreviousTag}}...{{.Tag}} 32 | 33 | homebrew_casks: 34 | - 35 | # NOTE: make sure the url_template, the token and given repo (github or gitlab) owner and name are from the 36 | # same kind. We will probably unify this in the next major version like it is done with scoop. 37 | 38 | repository: 39 | owner: suzuki-shunsuke 40 | name: homebrew-ghatm 41 | # The project name and current git tag are used in the format string. 42 | commit_msg_template: "Brew formula update for {{ .ProjectName }} version {{ .Tag }}" 43 | homepage: https://github.com/suzuki-shunsuke/ghatm 44 | description: | 45 | Set timeout-minutes to GitHub Actions jobs 46 | license: MIT 47 | skip_upload: true 48 | hooks: 49 | post: 50 | install: | 51 | if OS.mac? 52 | system_command "/usr/bin/xattr", args: ["-dr", "com.apple.quarantine", "#{staged_path}/ghatm"] 53 | end 54 | 55 | scoops: 56 | - 57 | description: | 58 | Set timeout-minutes to GitHub Actions jobs 59 | license: MIT 60 | skip_upload: true 61 | repository: 62 | owner: suzuki-shunsuke 63 | name: scoop-bucket 64 | 65 | sboms: 66 | - id: default 67 | disable: false 68 | -------------------------------------------------------------------------------- /pkg/edit/edit.go: -------------------------------------------------------------------------------- 1 | package edit 2 | 3 | import ( 4 | "bufio" 5 | "fmt" 6 | "strings" 7 | ) 8 | 9 | func Edit(content []byte, wf *Workflow, timeouts map[string]int, timeout int) ([]byte, error) { 10 | jobNames := ListJobsWithoutTimeout(wf.Jobs) 11 | positions, err := parseWorkflowAST(content, jobNames) 12 | if err != nil { 13 | return nil, err 14 | } 15 | if len(positions) == 0 { 16 | return nil, nil 17 | } 18 | 19 | lines, err := insertTimeout(content, positions, timeouts, timeout) 20 | if err != nil { 21 | return nil, err 22 | } 23 | return []byte(strings.Join(lines, "\n") + "\n"), nil 24 | } 25 | 26 | func ListJobsWithoutTimeout(jobs map[string]*Job) map[string]struct{} { 27 | m := make(map[string]struct{}, len(jobs)) 28 | for jobName, job := range jobs { 29 | if hasTimeout(job) { 30 | continue 31 | } 32 | m[jobName] = struct{}{} 33 | } 34 | return m 35 | } 36 | 37 | func hasTimeout(job *Job) bool { 38 | if job.TimeoutMinutes != nil || job.Uses != "" { 39 | return true 40 | } 41 | for _, step := range job.Steps { 42 | if step.TimeoutMinutes == nil { 43 | return false 44 | } 45 | } 46 | return true 47 | } 48 | 49 | func getTimeout(timeouts map[string]int, timeout int, jobKey string) int { 50 | if timeouts == nil { 51 | return timeout 52 | } 53 | if a, ok := timeouts[jobKey]; ok { 54 | return a 55 | } 56 | return -1 57 | } 58 | 59 | func insertTimeout(content []byte, positions []*Position, timeouts map[string]int, timeout int) ([]string, error) { 60 | reader := strings.NewReader(string(content)) 61 | scanner := bufio.NewScanner(reader) 62 | num := -1 63 | 64 | lines := []string{} 65 | pos := positions[0] 66 | lastPosIndex := len(positions) - 1 67 | posIndex := 0 68 | for scanner.Scan() { 69 | num++ 70 | line := scanner.Text() 71 | if pos.Line == num { 72 | indent := strings.Repeat(" ", pos.Column-1) 73 | if t := getTimeout(timeouts, timeout, pos.JobKey); t != -1 { 74 | lines = append(lines, indent+fmt.Sprintf("timeout-minutes: %d", t)) 75 | } 76 | if posIndex == lastPosIndex { 77 | pos.Line = -1 78 | } else { 79 | posIndex++ 80 | pos = positions[posIndex] 81 | } 82 | } 83 | lines = append(lines, line) 84 | } 85 | 86 | if err := scanner.Err(); err != nil { 87 | return nil, fmt.Errorf("scan a workflow file: %w", err) 88 | } 89 | return lines, nil 90 | } 91 | -------------------------------------------------------------------------------- /pkg/controller/set/workflow.go: -------------------------------------------------------------------------------- 1 | package set 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log/slog" 7 | 8 | "github.com/spf13/afero" 9 | "github.com/suzuki-shunsuke/ghatm/pkg/edit" 10 | "github.com/suzuki-shunsuke/ghatm/pkg/github" 11 | "gopkg.in/yaml.v3" 12 | ) 13 | 14 | type GitHub interface { 15 | ListWorkflowRuns(ctx context.Context, owner, repo, workflowFileName string, opts *github.ListWorkflowRunsOptions) ([]*github.WorkflowRun, *github.Response, error) 16 | ListWorkflowJobs(ctx context.Context, logger *slog.Logger, owner, repo string, runID int64, opts *github.ListWorkflowJobsOptions) ([]*github.WorkflowJob, *github.Response, error) 17 | } 18 | 19 | func handleWorkflow(ctx context.Context, logger *slog.Logger, fs afero.Fs, gh GitHub, file string, param *Param) error { 20 | content, err := afero.ReadFile(fs, file) 21 | if err != nil { 22 | return fmt.Errorf("read a file: %w", err) 23 | } 24 | 25 | wf := &edit.Workflow{} 26 | if err := yaml.Unmarshal(content, wf); err != nil { 27 | return fmt.Errorf("unmarshal a workflow file: %w", err) 28 | } 29 | if err := wf.Validate(); err != nil { 30 | return fmt.Errorf("validate a workflow: %w", err) 31 | } 32 | 33 | jobNames := edit.ListJobsWithoutTimeout(wf.Jobs) 34 | 35 | var timeouts map[string]int 36 | if param.Auto { 37 | tm, err := estimateTimeout(ctx, logger, gh, param, file, wf, jobNames) 38 | if err != nil { 39 | return err 40 | } 41 | timeouts = tm 42 | } 43 | 44 | after, err := edit.Edit(content, wf, timeouts, param.TimeoutMinutes) 45 | if err != nil { 46 | return fmt.Errorf("create a new workflow content: %w", err) 47 | } 48 | if after == nil { 49 | return nil 50 | } 51 | return writeWorkflow(fs, file, after) 52 | } 53 | 54 | func writeWorkflow(fs afero.Fs, file string, content []byte) error { 55 | stat, err := fs.Stat(file) 56 | if err != nil { 57 | return fmt.Errorf("get the workflow file stat: %w", err) 58 | } 59 | 60 | if err := afero.WriteFile(fs, file, content, stat.Mode()); err != nil { 61 | return fmt.Errorf("write the workflow file: %w", err) 62 | } 63 | return nil 64 | } 65 | 66 | func findWorkflows(fs afero.Fs) ([]string, error) { 67 | files, err := afero.Glob(fs, ".github/workflows/*.yml") 68 | if err != nil { 69 | return nil, fmt.Errorf("find .github/workflows/*.yml: %w", err) 70 | } 71 | files2, err := afero.Glob(fs, ".github/workflows/*.yaml") 72 | if err != nil { 73 | return nil, fmt.Errorf("find .github/workflows/*.yaml: %w", err) 74 | } 75 | return append(files, files2...), nil 76 | } 77 | -------------------------------------------------------------------------------- /pkg/edit/workflow.go: -------------------------------------------------------------------------------- 1 | package edit 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "regexp" 7 | "strings" 8 | 9 | "github.com/suzuki-shunsuke/slog-error/slogerr" 10 | ) 11 | 12 | type Workflow struct { 13 | Jobs map[string]*Job 14 | } 15 | 16 | type Job struct { 17 | Name string 18 | Steps []*Step 19 | Uses string 20 | TimeoutMinutes any `yaml:"timeout-minutes"` 21 | Strategy any 22 | } 23 | 24 | type Step struct { 25 | TimeoutMinutes any `yaml:"timeout-minutes"` 26 | } 27 | 28 | // foo (${{inputs.name}}) -> ^foo (.+?)$ 29 | 30 | var parameterRegexp = regexp.MustCompile(`\${{.+?}}`) 31 | 32 | func (j *Job) GetName(k string) (string, *regexp.Regexp, error) { 33 | if j.Strategy == nil { 34 | if j.Name == "" { 35 | return k, nil, nil 36 | } 37 | if !strings.Contains(j.Name, "${{") { 38 | return j.Name, nil, nil 39 | } 40 | r, err := regexp.Compile("^" + parameterRegexp.ReplaceAllLiteralString(j.Name, ".+") + "$") 41 | if err != nil { 42 | return "", nil, fmt.Errorf("convert a job name with parameters to a regular expression: %w", err) 43 | } 44 | return j.Name, r, nil 45 | } 46 | if j.Name == "" { 47 | r, err := regexp.Compile("^" + k + ` \(.*\)$`) 48 | if err != nil { 49 | return "", nil, fmt.Errorf("convert a job name with matrix to a regular expression: %w", err) 50 | } 51 | return k, r, nil 52 | } 53 | if !strings.Contains(j.Name, "${{") { 54 | r, err := regexp.Compile("^" + j.Name + ` \(.*\)$`) 55 | if err != nil { 56 | return "", nil, fmt.Errorf("convert a job name with matrix to a regular expression: %w", err) 57 | } 58 | return j.Name, r, nil 59 | } 60 | r, err := regexp.Compile("^" + parameterRegexp.ReplaceAllLiteralString(j.Name, ".+") + "$") 61 | if err != nil { 62 | return "", nil, fmt.Errorf("convert a job name with parameters to a regular expression: %w", err) 63 | } 64 | return j.Name, r, nil 65 | } 66 | 67 | func (w *Workflow) Validate() error { 68 | if w == nil { 69 | return errors.New("workflow is nil") 70 | } 71 | if len(w.Jobs) == 0 { 72 | return errors.New("jobs are empty") 73 | } 74 | for jobName, job := range w.Jobs { 75 | if err := job.Validate(); err != nil { 76 | return slogerr.With(err, "job", jobName) //nolint:wrapcheck 77 | } 78 | } 79 | return nil 80 | } 81 | 82 | func (j *Job) Validate() error { 83 | if j == nil { 84 | return errors.New("job is nil") 85 | } 86 | for _, step := range j.Steps { 87 | if err := step.Validate(); err != nil { 88 | return err 89 | } 90 | } 91 | return nil 92 | } 93 | 94 | func (s *Step) Validate() error { 95 | if s == nil { 96 | return errors.New("step is nil") 97 | } 98 | return nil 99 | } 100 | -------------------------------------------------------------------------------- /USAGE.md: -------------------------------------------------------------------------------- 1 | # Usage 2 | 3 | 4 | 5 | ```console 6 | $ ghatm --help 7 | NAME: 8 | ghatm - Set timeout-minutes to all GitHub Actions jobs. https://github.com/suzuki-shunsuke/ghatm 9 | 10 | USAGE: 11 | ghatm [global options] [command [command options]] 12 | 13 | VERSION: 14 | 1.0.0 15 | 16 | COMMANDS: 17 | set Set timeout-minutes to GitHub Actions jobs which don't have timeout-minutes 18 | version Show version 19 | help, h Shows a list of commands or help for one command 20 | completion Output shell completion script for bash, zsh, fish, or Powershell 21 | 22 | GLOBAL OPTIONS: 23 | --log-level string log level 24 | --log-color string Log color. One of 'auto', 'always' (default), 'never' 25 | --help, -h show help 26 | --version, -v print the version 27 | ``` 28 | 29 | ## ghatm set 30 | 31 | ```console 32 | $ ghatm set --help 33 | NAME: 34 | ghatm set - Set timeout-minutes to GitHub Actions jobs which don't have timeout-minutes 35 | 36 | USAGE: 37 | ghatm set 38 | 39 | DESCRIPTION: 40 | Set timeout-minutes to GitHub Actions jobs which don't have timeout-minutes. 41 | 42 | $ ghatm set 43 | 44 | 45 | OPTIONS: 46 | --log-level string log level 47 | --log-color string Log color. One of 'auto', 'always' (default), 'never' 48 | --timeout-minutes int, -t int The value of timeout-minutes (default: 30) 49 | --auto, -a Estimate the value of timeout-minutes automatically 50 | --repo string, -r string GitHub Repository [$GITHUB_REPOSITORY] 51 | --size int, -s int Data size (default: 30) 52 | --help, -h show help 53 | ``` 54 | 55 | ## ghatm version 56 | 57 | ```console 58 | $ ghatm version --help 59 | NAME: 60 | ghatm version - Show version 61 | 62 | USAGE: 63 | ghatm version 64 | 65 | OPTIONS: 66 | --json, -j Output version in JSON format 67 | --help, -h show help 68 | ``` 69 | 70 | ## ghatm completion 71 | 72 | ```console 73 | $ ghatm completion --help 74 | NAME: 75 | ghatm completion - Output shell completion script for bash, zsh, fish, or Powershell 76 | 77 | USAGE: 78 | ghatm completion 79 | 80 | DESCRIPTION: 81 | Output shell completion script for bash, zsh, fish, or Powershell. 82 | Source the output to enable completion. 83 | 84 | # .bashrc 85 | source <(ghatm completion bash) 86 | 87 | # .zshrc 88 | source <(ghatm completion zsh) 89 | 90 | # fish 91 | ghatm completion fish > ~/.config/fish/completions/ghatm.fish 92 | 93 | # Powershell 94 | Output the script to path/to/autocomplete/ghatm.ps1 an run it. 95 | 96 | 97 | OPTIONS: 98 | --help, -h show help 99 | ``` 100 | -------------------------------------------------------------------------------- /pkg/cli/set.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "strings" 8 | 9 | "github.com/spf13/afero" 10 | "github.com/suzuki-shunsuke/ghatm/pkg/controller/set" 11 | "github.com/suzuki-shunsuke/slog-util/slogutil" 12 | "github.com/urfave/cli/v3" 13 | ) 14 | 15 | type SetFlags struct { 16 | *GlobalFlags 17 | 18 | TimeoutMinutes int 19 | Auto bool 20 | Repo string 21 | Size int 22 | Files []string 23 | } 24 | 25 | type setCommand struct{} 26 | 27 | func (rc *setCommand) command(logger *slogutil.Logger, globalFlags *GlobalFlags) *cli.Command { 28 | flags := &SetFlags{GlobalFlags: globalFlags} 29 | return &cli.Command{ 30 | Name: "set", 31 | Usage: "Set timeout-minutes to GitHub Actions jobs which don't have timeout-minutes", 32 | Action: func(ctx context.Context, _ *cli.Command) error { 33 | return rc.action(ctx, logger, flags) 34 | }, 35 | Arguments: []cli.Argument{ 36 | &cli.StringArgs{ 37 | Name: "file", 38 | Destination: &flags.Files, 39 | Max: -1, 40 | }, 41 | }, 42 | Flags: []cli.Flag{ 43 | &cli.StringFlag{ 44 | Name: "log-level", 45 | Usage: "log level", 46 | Destination: &flags.LogLevel, 47 | }, 48 | &cli.StringFlag{ 49 | Name: "log-color", 50 | Usage: "Log color. One of 'auto', 'always' (default), 'never'", 51 | Destination: &flags.LogColor, 52 | }, 53 | &cli.IntFlag{ 54 | Name: "timeout-minutes", 55 | Aliases: []string{"t"}, 56 | Usage: "The value of timeout-minutes", 57 | Value: 30, //nolint:mnd 58 | Destination: &flags.TimeoutMinutes, 59 | }, 60 | &cli.BoolFlag{ 61 | Name: "auto", 62 | Aliases: []string{"a"}, 63 | Usage: "Estimate the value of timeout-minutes automatically", 64 | Destination: &flags.Auto, 65 | }, 66 | &cli.StringFlag{ 67 | Name: "repo", 68 | Aliases: []string{"r"}, 69 | Usage: "GitHub Repository", 70 | Sources: cli.EnvVars("GITHUB_REPOSITORY"), 71 | Destination: &flags.Repo, 72 | }, 73 | &cli.IntFlag{ 74 | Name: "size", 75 | Aliases: []string{"s"}, 76 | Usage: "Data size", 77 | Value: 30, //nolint:mnd 78 | Destination: &flags.Size, 79 | }, 80 | }, 81 | } 82 | } 83 | 84 | func (rc *setCommand) action(ctx context.Context, logger *slogutil.Logger, flags *SetFlags) error { 85 | fs := afero.NewOsFs() 86 | if err := logger.SetLevel(flags.LogLevel); err != nil { 87 | return fmt.Errorf("set log level: %w", err) 88 | } 89 | if err := logger.SetColor(flags.LogColor); err != nil { 90 | return fmt.Errorf("set log color: %w", err) 91 | } 92 | param := &set.Param{ 93 | Files: flags.Files, 94 | TimeoutMinutes: flags.TimeoutMinutes, 95 | Auto: flags.Auto, 96 | Size: flags.Size, 97 | } 98 | if param.Auto && flags.Repo == "" { 99 | return errors.New("the flag -auto requires the flag -repo") 100 | } 101 | if flags.Repo != "" { 102 | owner, repoName, ok := strings.Cut(flags.Repo, "/") 103 | if !ok { 104 | return fmt.Errorf("split the repository name: %s", flags.Repo) 105 | } 106 | param.RepoOwner = owner 107 | param.RepoName = repoName 108 | } 109 | return set.Set(ctx, logger.Logger, fs, param) //nolint:wrapcheck 110 | } 111 | -------------------------------------------------------------------------------- /pkg/edit/ast.go: -------------------------------------------------------------------------------- 1 | package edit 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | 7 | "github.com/goccy/go-yaml/ast" 8 | "github.com/goccy/go-yaml/parser" 9 | "github.com/suzuki-shunsuke/slog-error/slogerr" 10 | ) 11 | 12 | type Position struct { 13 | JobKey string 14 | Line int 15 | Column int 16 | } 17 | 18 | func parseWorkflowAST(content []byte, jobNames map[string]struct{}) ([]*Position, error) { 19 | file, err := parser.ParseBytes(content, parser.ParseComments) 20 | if err != nil { 21 | return nil, fmt.Errorf("parse a workflow file as YAML: %w", err) 22 | } 23 | list := []*Position{} 24 | for _, doc := range file.Docs { 25 | arr, err := parseDocAST(doc, jobNames) 26 | if err != nil { 27 | return nil, err 28 | } 29 | if len(arr) == 0 { 30 | continue 31 | } 32 | list = append(list, arr...) 33 | } 34 | return list, nil 35 | } 36 | 37 | func parseDocAST(doc *ast.DocumentNode, jobNames map[string]struct{}) ([]*Position, error) { 38 | body, ok := doc.Body.(*ast.MappingNode) 39 | if !ok { 40 | return nil, errors.New("document body must be *ast.MappingNode") 41 | } 42 | // jobs: 43 | // jobName: 44 | // timeout-minutes: 10 45 | // steps: 46 | jobsNode := findJobsNode(body.Values) 47 | if jobsNode == nil { 48 | return nil, errors.New("the field 'jobs' is required") 49 | } 50 | return parseDocValue(jobsNode, jobNames) 51 | } 52 | 53 | func findJobsNode(values []*ast.MappingValueNode) *ast.MappingValueNode { 54 | for _, value := range values { 55 | key, ok := value.Key.(*ast.StringNode) 56 | if !ok { 57 | continue 58 | } 59 | if key.Value == "jobs" { 60 | return value 61 | } 62 | } 63 | return nil 64 | } 65 | 66 | func getMappingValueNodes(value *ast.MappingValueNode) ([]*ast.MappingValueNode, error) { 67 | switch node := value.Value.(type) { 68 | case *ast.MappingNode: 69 | return node.Values, nil 70 | case *ast.MappingValueNode: 71 | return []*ast.MappingValueNode{node}, nil 72 | } 73 | return nil, errors.New("value must be either a *ast.MappingNode or a *ast.MappingValueNode") 74 | } 75 | 76 | func parseDocValue(value *ast.MappingValueNode, jobNames map[string]struct{}) ([]*Position, error) { 77 | values, err := getMappingValueNodes(value) 78 | if err != nil { 79 | return nil, err 80 | } 81 | arr := make([]*Position, 0, len(values)) 82 | for _, job := range values { 83 | pos, err := parseJobAST(job, jobNames) 84 | if err != nil { 85 | return nil, err 86 | } 87 | if pos == nil { 88 | continue 89 | } 90 | arr = append(arr, pos) 91 | } 92 | return arr, nil 93 | } 94 | 95 | func parseJobAST(value *ast.MappingValueNode, jobNames map[string]struct{}) (*Position, error) { 96 | jobNameNode, ok := value.Key.(*ast.StringNode) 97 | if !ok { 98 | return nil, errors.New("job name must be a string") 99 | } 100 | jobName := jobNameNode.Value 101 | if _, ok := jobNames[jobName]; !ok { 102 | return nil, nil //nolint:nilnil 103 | } 104 | fields, err := getMappingValueNodes(value) 105 | if err != nil { 106 | return nil, slogerr.With(err, "job", jobName) //nolint:wrapcheck 107 | } 108 | if len(fields) == 0 { 109 | return nil, slogerr.With(errors.New("job doesn't have any field"), "job", jobName) //nolint:wrapcheck 110 | } 111 | firstValue := fields[0] 112 | pos := firstValue.Key.GetToken().Position 113 | return &Position{ 114 | JobKey: jobName, 115 | Line: pos.Line - 1, 116 | Column: pos.Column, 117 | }, nil 118 | } 119 | -------------------------------------------------------------------------------- /pkg/edit/edit_internal_test.go: -------------------------------------------------------------------------------- 1 | package edit 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | "testing" 7 | 8 | "github.com/google/go-cmp/cmp" 9 | ) 10 | 11 | func TestEdit(t *testing.T) { //nolint:gocognit,cyclop,funlen 12 | t.Parallel() 13 | data := []struct { 14 | name string 15 | content string 16 | result string 17 | isErr bool 18 | wf *Workflow 19 | timeouts map[string]int 20 | }{ 21 | { 22 | name: "normal", 23 | content: "normal.yaml", 24 | result: "normal_result.yaml", 25 | wf: &Workflow{ 26 | Jobs: map[string]*Job{ 27 | "actionlint": { 28 | Uses: "suzuki-shunsuke/actionlint-workflow/.github/workflows/actionlint.yaml@813a6d08c08cfd7a08618a89a59bfe78e573597c # v1.0.1", 29 | }, 30 | "foo": { 31 | TimeoutMinutes: 5, 32 | Steps: []*Step{ 33 | {}, 34 | }, 35 | }, 36 | "bar": { 37 | Steps: []*Step{ 38 | { 39 | TimeoutMinutes: 5, 40 | }, 41 | }, 42 | }, 43 | "zoo": { 44 | Steps: []*Step{ 45 | {}, 46 | }, 47 | }, 48 | "yoo": { 49 | Steps: []*Step{ 50 | {}, 51 | }, 52 | }, 53 | }, 54 | }, 55 | }, 56 | { 57 | name: "nochange", 58 | content: "nochange.yaml", 59 | wf: &Workflow{ 60 | Jobs: map[string]*Job{ 61 | "actionlint": { 62 | Uses: "suzuki-shunsuke/actionlint-workflow/.github/workflows/actionlint.yaml@813a6d08c08cfd7a08618a89a59bfe78e573597c # v1.0.1", 63 | }, 64 | "foo": { 65 | TimeoutMinutes: 5, 66 | Steps: []*Step{ 67 | {}, 68 | }, 69 | }, 70 | "bar": { 71 | Steps: []*Step{ 72 | { 73 | TimeoutMinutes: 5, 74 | }, 75 | }, 76 | }, 77 | }, 78 | }, 79 | }, 80 | { 81 | // The tool should recognize ${{ inputs.timeout }} in with-timeout job and add timeout to without-timeout job 82 | name: "reusable_workflow_timeout", 83 | content: "reusable_workflow_timeout.yaml", 84 | result: "reusable_workflow_timeout_result.yaml", 85 | wf: &Workflow{ 86 | Jobs: map[string]*Job{ 87 | "with-timeout": { 88 | TimeoutMinutes: "${{ inputs.timeout }}", // This should be detected as having a timeout via inputs 89 | Steps: []*Step{ 90 | {}, 91 | }, 92 | }, 93 | "without-timeout": { 94 | TimeoutMinutes: nil, // This should get a default timeout added 95 | Steps: []*Step{ 96 | {}, 97 | }, 98 | }, 99 | }, 100 | }, 101 | }, 102 | } 103 | for _, d := range data { 104 | t.Run(d.name, func(t *testing.T) { 105 | t.Parallel() 106 | content, err := os.ReadFile(filepath.Join("testdata", d.content)) 107 | if err != nil { 108 | t.Fatal(err) 109 | } 110 | var expResult []byte 111 | if d.result != "" { 112 | content, err := os.ReadFile(filepath.Join("testdata", d.result)) 113 | if err != nil { 114 | t.Fatal(err) 115 | } 116 | expResult = content 117 | } 118 | result, err := Edit(content, d.wf, d.timeouts, 30) 119 | if err != nil { 120 | if d.isErr { 121 | return 122 | } 123 | t.Fatal(err) 124 | } 125 | if result == nil { 126 | if expResult == nil { 127 | return 128 | } 129 | t.Fatalf("wanted %v, got nil", string(expResult)) 130 | } 131 | if expResult == nil { 132 | t.Fatalf("wanted nil, got %v", string(result)) 133 | } 134 | if diff := cmp.Diff(string(expResult), string(result)); diff != "" { 135 | t.Fatal(diff) 136 | } 137 | }) 138 | } 139 | } 140 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 2 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 3 | github.com/goccy/go-yaml v1.19.1 h1:3rG3+v8pkhRqoQ/88NYNMHYVGYztCOCIZ7UQhu7H+NE= 4 | github.com/goccy/go-yaml v1.19.1/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= 5 | github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 6 | github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= 7 | github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 8 | github.com/google/go-github/v80 v80.0.0 h1:BTyk3QOHekrk5VF+jIGz1TNEsmeoQG9K/UWaaP+EWQs= 9 | github.com/google/go-github/v80 v80.0.0/go.mod h1:pRo4AIMdHW83HNMGfNysgSAv0vmu+/pkY8nZO9FT9Yo= 10 | github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= 11 | github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= 12 | github.com/lmittmann/tint v1.1.2 h1:2CQzrL6rslrsyjqLDwD11bZ5OpLBPU+g3G/r5LSfS8w= 13 | github.com/lmittmann/tint v1.1.2/go.mod h1:HIS3gSy7qNwGCj+5oRjAutErFBl4BzdQP6cJZ0NfMwE= 14 | github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= 15 | github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= 16 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 17 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 18 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 19 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 20 | github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= 21 | github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= 22 | github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= 23 | github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= 24 | github.com/suzuki-shunsuke/go-error-with-exit-code v1.0.0 h1:oVXrrYNGBq4POyITQNWKzwsYz7B2nUcqtDbeX4BfeEc= 25 | github.com/suzuki-shunsuke/go-error-with-exit-code v1.0.0/go.mod h1:kDFtLeftDiIUUHXGI3xq5eJ+uAOi50FPrxPENTHktJ0= 26 | github.com/suzuki-shunsuke/slog-error v0.2.1 h1:zcWOEo451RWmgusiONt/GueyvkTL7n4qA0ZJ3gTEjbA= 27 | github.com/suzuki-shunsuke/slog-error v0.2.1/go.mod h1:w45QyO2G0uiEuo9hhrcLqqRl3hmYon9jGgq9CrCxxOY= 28 | github.com/suzuki-shunsuke/slog-util v0.3.0 h1:s+Go2yZqBnJCyV4kj1MDJEITfS7ELdDAEKk/aCulBkQ= 29 | github.com/suzuki-shunsuke/slog-util v0.3.0/go.mod h1:PgZMd+2rC8pA9jBbXDfkI8mTuWYAiaVkKxjrbLtfN5I= 30 | github.com/suzuki-shunsuke/urfave-cli-v3-util v0.2.0 h1:ORT/qQxsKuWwuy2N/z2f2hmbKWmlS346/j4jGhxsxLo= 31 | github.com/suzuki-shunsuke/urfave-cli-v3-util v0.2.0/go.mod h1:BYtzUgA4oeUVUFoJIONWOquvIUy0cl7DpAeCya3mVJU= 32 | github.com/urfave/cli/v3 v3.6.1 h1:j8Qq8NyUawj/7rTYdBGrxcH7A/j7/G8Q5LhWEW4G3Mo= 33 | github.com/urfave/cli/v3 v3.6.1/go.mod h1:ysVLtOEmg2tOy6PknnYVhDoouyC/6N42TMeoMzskhso= 34 | golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw= 35 | golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= 36 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 37 | golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= 38 | golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= 39 | golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= 40 | golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= 41 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 42 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 43 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 44 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 45 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 46 | -------------------------------------------------------------------------------- /pkg/controller/set/estimate.go: -------------------------------------------------------------------------------- 1 | package set 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log/slog" 7 | "maps" 8 | "math" 9 | "path/filepath" 10 | "regexp" 11 | "slices" 12 | "strings" 13 | "time" 14 | 15 | "github.com/suzuki-shunsuke/ghatm/pkg/edit" 16 | "github.com/suzuki-shunsuke/ghatm/pkg/github" 17 | "github.com/suzuki-shunsuke/slog-error/slogerr" 18 | ) 19 | 20 | func setNamePatterns(jobs map[string]*edit.Job, jobKeys map[string]struct{}, staticNames map[string]string, namePatterns map[string]*regexp.Regexp) error { 21 | for jobKey, job := range jobs { 22 | if _, ok := jobKeys[jobKey]; !ok { 23 | continue 24 | } 25 | name, nameRegexp, err := job.GetName(jobKey) 26 | if err != nil { 27 | return fmt.Errorf("get a job name: %w", slogerr.With(err, "job_key", jobKey)) 28 | } 29 | if nameRegexp == nil { 30 | staticNames[name] = jobKey 31 | continue 32 | } 33 | namePatterns[jobKey] = nameRegexp 34 | } 35 | return nil 36 | } 37 | 38 | func handleJob(logger *slog.Logger, jobDurationMap map[string][]time.Duration, staticNames map[string]string, namePatterns map[string]*regexp.Regexp, job *github.WorkflowJob) { 39 | if jobKey, ok := staticNames[job.Name]; ok { 40 | logger.Debug("adding the job duration", "job_name", job.Name, "job_key", jobKey) 41 | a, ok := jobDurationMap[jobKey] 42 | if !ok { 43 | a = []time.Duration{} 44 | } 45 | a = append(a, job.Duration) 46 | jobDurationMap[jobKey] = a 47 | return 48 | } 49 | for jobKey, nameRegexp := range namePatterns { 50 | if !nameRegexp.MatchString(job.Name) { 51 | continue 52 | } 53 | logger.Debug("adding the job duration", "job_name", job.Name, "job_key", jobKey, "job_name_pattern", nameRegexp.String()) 54 | a, ok := jobDurationMap[jobKey] 55 | if !ok { 56 | a = []time.Duration{} 57 | } 58 | a = append(a, job.Duration) 59 | jobDurationMap[jobKey] = a 60 | return 61 | } 62 | logger.Debug("the job name doesn't match", "job_name", job.Name) 63 | } 64 | 65 | func handleWorkflowRun(ctx context.Context, logger *slog.Logger, gh GitHub, param *Param, jobDurationMap map[string][]time.Duration, staticNames map[string]string, namePatterns map[string]*regexp.Regexp, runID int64) (bool, error) { 66 | jobOpts := &github.ListWorkflowJobsOptions{ 67 | Status: "success", 68 | } 69 | for range 10 { 70 | if isCompleted(logger, jobDurationMap, param.Size) { 71 | return true, nil 72 | } 73 | jobs, resp, err := gh.ListWorkflowJobs(ctx, logger, param.RepoOwner, param.RepoName, runID, jobOpts) 74 | if err != nil { 75 | return false, fmt.Errorf("list workflow jobs: %w", slogerr.With(err, "workflow_run_id", runID)) 76 | } 77 | logger.Debug("list workflow jobs", "num_of_jobs", len(jobs)) 78 | for _, job := range jobs { 79 | logger := logger.With("job_name", job.Name, "job_status", job.Status, "job_duration", job.Duration) 80 | if isCompleted(logger, jobDurationMap, param.Size) { 81 | logger.Debug("job has been completed") 82 | return true, nil 83 | } 84 | logger.Debug("handling the job") 85 | handleJob(logger, jobDurationMap, staticNames, namePatterns, job) 86 | } 87 | if resp.NextPage == 0 { 88 | break 89 | } 90 | jobOpts.Page = resp.NextPage 91 | } 92 | return false, nil 93 | } 94 | 95 | // getJobsByAPI gets each job's durations by the GitHub API. 96 | // It returns a map of job key and durations. 97 | func getJobsByAPI(ctx context.Context, logger *slog.Logger, gh GitHub, param *Param, file string, wf *edit.Workflow, jobKeys map[string]struct{}) (map[string][]time.Duration, error) { 98 | // jobName -> jobKey 99 | staticNames := make(map[string]string, len(wf.Jobs)) 100 | // jobKey -> regular expression of job name 101 | namePatterns := make(map[string]*regexp.Regexp, len(wf.Jobs)) 102 | if err := setNamePatterns(wf.Jobs, jobKeys, staticNames, namePatterns); err != nil { 103 | return nil, err 104 | } 105 | logger.Debug("static names and name patterns", "static_names", strings.Join(slices.Collect(maps.Keys(staticNames)), ", "), "name_patterns", strings.Join(slices.Collect(maps.Keys(namePatterns)), ", ")) 106 | 107 | jobDurationMap := make(map[string][]time.Duration, len(wf.Jobs)) 108 | for jobKey := range jobKeys { 109 | jobDurationMap[jobKey] = []time.Duration{} 110 | } 111 | 112 | runOpts := &github.ListWorkflowRunsOptions{ 113 | Status: "success", 114 | } 115 | loopSize := int(math.Ceil(float64(param.Size) * 3.0 / 100)) //nolint:mnd 116 | for range loopSize { 117 | runs, resp, err := gh.ListWorkflowRuns(ctx, param.RepoOwner, param.RepoName, file, runOpts) 118 | if err != nil { 119 | return nil, fmt.Errorf("list workflow runs: %w", err) 120 | } 121 | logger.Debug("list workflow runs", "num_of_runs", len(runs)) 122 | for _, run := range runs { 123 | completed, err := handleWorkflowRun(ctx, logger, gh, param, jobDurationMap, staticNames, namePatterns, run.ID) 124 | if err != nil { 125 | return nil, err 126 | } 127 | if completed { 128 | return jobDurationMap, nil 129 | } 130 | } 131 | if resp.NextPage == 0 { 132 | return jobDurationMap, nil 133 | } 134 | runOpts.Page = resp.NextPage 135 | } 136 | return jobDurationMap, nil 137 | } 138 | 139 | func isCompleted(logger *slog.Logger, jobDurationMap map[string][]time.Duration, size int) bool { 140 | for jobKey, durations := range jobDurationMap { 141 | if len(durations) < size { 142 | logger.Debug("the job hasn't been completed", "job_key", jobKey, "param_size", size, "num_of_durations", len(durations)) 143 | return false 144 | } 145 | } 146 | return true 147 | } 148 | 149 | // estimateTimeout estimates each job's timeout-minutes. 150 | // It returns a map of job key and timeout-minutes. 151 | // If there is no job's duration, the job is excluded from the return value. 152 | func estimateTimeout(ctx context.Context, logger *slog.Logger, gh GitHub, param *Param, file string, wf *edit.Workflow, jobKeys map[string]struct{}) (map[string]int, error) { 153 | fileName := filepath.Base(file) 154 | jobs, err := getJobsByAPI(ctx, logger, gh, param, fileName, wf, jobKeys) 155 | if err != nil { 156 | return nil, err 157 | } 158 | 159 | // Each job's timeout-minutes is `max(durations) + 10`. 160 | m := make(map[string]int, len(jobs)) 161 | for jobKey, durations := range jobs { 162 | if len(durations) == 0 { 163 | logger.Warn("the job is ignored because the job wasn't executed", "job_key", jobKey) 164 | continue 165 | } 166 | maxDuration := slices.Max(durations) 167 | m[jobKey] = int(math.Ceil(maxDuration.Minutes())) + 10 //nolint:mnd 168 | } 169 | 170 | return m, nil 171 | } 172 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ghatm 2 | 3 | [![DeepWiki](https://img.shields.io/badge/DeepWiki-suzuki--shunsuke%2Fghatm-blue.svg?logo=data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACwAAAAyCAYAAAAnWDnqAAAAAXNSR0IArs4c6QAAA05JREFUaEPtmUtyEzEQhtWTQyQLHNak2AB7ZnyXZMEjXMGeK/AIi+QuHrMnbChYY7MIh8g01fJoopFb0uhhEqqcbWTp06/uv1saEDv4O3n3dV60RfP947Mm9/SQc0ICFQgzfc4CYZoTPAswgSJCCUJUnAAoRHOAUOcATwbmVLWdGoH//PB8mnKqScAhsD0kYP3j/Yt5LPQe2KvcXmGvRHcDnpxfL2zOYJ1mFwrryWTz0advv1Ut4CJgf5uhDuDj5eUcAUoahrdY/56ebRWeraTjMt/00Sh3UDtjgHtQNHwcRGOC98BJEAEymycmYcWwOprTgcB6VZ5JK5TAJ+fXGLBm3FDAmn6oPPjR4rKCAoJCal2eAiQp2x0vxTPB3ALO2CRkwmDy5WohzBDwSEFKRwPbknEggCPB/imwrycgxX2NzoMCHhPkDwqYMr9tRcP5qNrMZHkVnOjRMWwLCcr8ohBVb1OMjxLwGCvjTikrsBOiA6fNyCrm8V1rP93iVPpwaE+gO0SsWmPiXB+jikdf6SizrT5qKasx5j8ABbHpFTx+vFXp9EnYQmLx02h1QTTrl6eDqxLnGjporxl3NL3agEvXdT0WmEost648sQOYAeJS9Q7bfUVoMGnjo4AZdUMQku50McDcMWcBPvr0SzbTAFDfvJqwLzgxwATnCgnp4wDl6Aa+Ax283gghmj+vj7feE2KBBRMW3FzOpLOADl0Isb5587h/U4gGvkt5v60Z1VLG8BhYjbzRwyQZemwAd6cCR5/XFWLYZRIMpX39AR0tjaGGiGzLVyhse5C9RKC6ai42ppWPKiBagOvaYk8lO7DajerabOZP46Lby5wKjw1HCRx7p9sVMOWGzb/vA1hwiWc6jm3MvQDTogQkiqIhJV0nBQBTU+3okKCFDy9WwferkHjtxib7t3xIUQtHxnIwtx4mpg26/HfwVNVDb4oI9RHmx5WGelRVlrtiw43zboCLaxv46AZeB3IlTkwouebTr1y2NjSpHz68WNFjHvupy3q8TFn3Hos2IAk4Ju5dCo8B3wP7VPr/FGaKiG+T+v+TQqIrOqMTL1VdWV1DdmcbO8KXBz6esmYWYKPwDL5b5FA1a0hwapHiom0r/cKaoqr+27/XcrS5UwSMbQAAAABJRU5ErkJggg==)](https://deepwiki.com/suzuki-shunsuke/ghatm) 4 | 5 | `ghatm` is a command line tool setting [timeout-minutes](https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idtimeout-minutes) to all GitHub Actions jobs. 6 | It finds GitHub Actions workflows and adds `timeout-minutes` to jobs which don't have the setting. 7 | It edits workflow files while keeping YAML comments, indents, empty lines, and so on. 8 | 9 | ```console 10 | $ ghatm set 11 | ``` 12 | 13 | ```diff 14 | diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml 15 | index e8c6ae7..aba3b2d 100644 16 | --- a/.github/workflows/test.yaml 17 | +++ b/.github/workflows/test.yaml 18 | @@ -6,6 +6,7 @@ on: pull_request 19 | jobs: 20 | path-filter: 21 | # Get changed files to filter jobs 22 | + timeout-minutes: 30 23 | outputs: 24 | update-aqua-checksums: ${{steps.changes.outputs.update-aqua-checksums}} 25 | renovate-config-validator: ${{steps.changes.outputs.renovate-config-validator}} 26 | @@ -71,6 +72,7 @@ jobs: 27 | contents: read 28 | 29 | build: 30 | + timeout-minutes: 30 31 | runs-on: ubuntu-latest 32 | permissions: {} 33 | steps: 34 | ``` 35 | 36 | ## Motivation 37 | 38 | - https://exercism.org/docs/building/github/gha-best-practices#h-set-timeouts-for-workflows 39 | - [job_timeout_minutes_is_required | suzuki-shunsuke/ghalint](https://github.com/suzuki-shunsuke/ghalint/blob/main/docs/policies/012.md) 40 | - [job_timeout_minutes_is_required | lintnet-modules/ghalint](https://github.com/lintnet-modules/ghalint/tree/main/workflow/job_timeout_minutes_is_required) 41 | 42 | `timeout-minutes` should be set properly, but it's so bothersome to fix a lot of workflow files by hand. 43 | `ghatm` fixes them automatically. 44 | 45 | ## How to install 46 | 47 | `ghatm` is a single binary written in Go. 48 | So you only need to put the executable binary into `$PATH`. 49 | 50 | 1. [Homebrew](https://brew.sh/) 51 | 52 | ```sh 53 | brew install suzuki-shunsuke/ghatm/ghatm 54 | ``` 55 | 56 | 2. [Scoop](https://scoop.sh/) 57 | 58 | ```sh 59 | scoop bucket add suzuki-shunsuke https://github.com/suzuki-shunsuke/scoop-bucket 60 | scoop install ghatm 61 | ``` 62 | 63 | 3. [aqua](https://aquaproj.github.io/) 64 | 65 | ```sh 66 | aqua g -i suzuki-shunsuke/ghatm 67 | ``` 68 | 69 | 4. Download a prebuilt binary from [GitHub Releases](https://github.com/suzuki-shunsuke/ghatm/releases) and install it into `$PATH` 70 | 71 |
72 | Verify downloaded assets from GitHub Releases 73 | 74 | You can verify downloaded assets using some tools. 75 | 76 | 1. [GitHub CLI](https://cli.github.com/) 77 | 1. [slsa-verifier](https://github.com/slsa-framework/slsa-verifier) 78 | 1. [Cosign](https://github.com/sigstore/cosign) 79 | 80 | -- 81 | 82 | 1. GitHub CLI 83 | 84 | ghatm >= v0.3.3 85 | 86 | You can install GitHub CLI by aqua. 87 | 88 | ```sh 89 | aqua g -i cli/cli 90 | ``` 91 | 92 | ```sh 93 | gh release download -R suzuki-shunsuke/ghatm v0.3.3 -p ghatm_darwin_arm64.tar.gz 94 | gh attestation verify ghatm_darwin_arm64.tar.gz \ 95 | -R suzuki-shunsuke/ghatm \ 96 | --signer-workflow suzuki-shunsuke/go-release-workflow/.github/workflows/release.yaml 97 | ``` 98 | 99 | Output: 100 | 101 | ``` 102 | Loaded digest sha256:84298e8436f0b2c7f51cd4606848635471a11aaa03d7d0c410727630defe6b7e for file://ghatm_darwin_arm64.tar.gz 103 | Loaded 1 attestation from GitHub API 104 | ✓ Verification succeeded! 105 | 106 | sha256:84298e8436f0b2c7f51cd4606848635471a11aaa03d7d0c410727630defe6b7e was attested by: 107 | REPO PREDICATE_TYPE WORKFLOW 108 | suzuki-shunsuke/go-release-workflow https://slsa.dev/provenance/v1 .github/workflows/release.yaml@7f97a226912ee2978126019b1e95311d7d15c97a 109 | ``` 110 | 111 | 2. slsa-verifier 112 | 113 | You can install slsa-verifier by aqua. 114 | 115 | ```sh 116 | aqua g -i slsa-framework/slsa-verifier 117 | ``` 118 | 119 | ```sh 120 | gh release download -R suzuki-shunsuke/ghatm v0.3.3 -p ghatm_darwin_arm64.tar.gz -p multiple.intoto.jsonl 121 | slsa-verifier verify-artifact ghatm_darwin_arm64.tar.gz \ 122 | --provenance-path multiple.intoto.jsonl \ 123 | --source-uri github.com/suzuki-shunsuke/ghatm \ 124 | --source-tag v0.3.3 125 | ``` 126 | 127 | Output: 128 | 129 | ``` 130 | Verified signature against tlog entry index 137035428 at URL: https://rekor.sigstore.dev/api/v1/log/entries/108e9186e8c5677a421587935f03afc5f73475e880b6f05962c5be8726ccb5011b7bf62a5d2a58bb 131 | Verified build using builder "https://github.com/slsa-framework/slsa-github-generator/.github/workflows/generator_generic_slsa3.yml@refs/tags/v2.0.0" at commit 1af80d4aa0b6cc45bda5677fd45202ee2b90e1fc 132 | Verifying artifact ghatm_darwin_arm64.tar.gz: PASSED 133 | ``` 134 | 135 | 3. Cosign 136 | 137 | You can install Cosign by aqua. 138 | 139 | ```sh 140 | aqua g -i sigstore/cosign 141 | ``` 142 | 143 | ```sh 144 | gh release download -R suzuki-shunsuke/ghatm v0.3.3 145 | cosign verify-blob \ 146 | --signature ghatm_0.3.3_checksums.txt.sig \ 147 | --certificate ghatm_0.3.3_checksums.txt.pem \ 148 | --certificate-identity-regexp 'https://github\.com/suzuki-shunsuke/go-release-workflow/\.github/workflows/release\.yaml@.*' \ 149 | --certificate-oidc-issuer "https://token.actions.githubusercontent.com" \ 150 | ghatm_0.3.3_checksums.txt 151 | ``` 152 | 153 | Output: 154 | 155 | ``` 156 | Verified OK 157 | ``` 158 | 159 | After verifying the checksum, verify the artifact. 160 | 161 | ```sh 162 | cat ghatm_0.3.3_checksums.txt | sha256sum -c --ignore-missing 163 | ``` 164 | 165 |
166 | 167 | 5. Go 168 | 169 | ```sh 170 | go install github.com/suzuki-shunsuke/ghatm/cmd/ghatm@latest 171 | ``` 172 | 173 | ## How to use 174 | 175 | Please run `ghatm set` on the repository root directory. 176 | 177 | ```sh 178 | ghatm set 179 | ``` 180 | 181 | Then `ghatm` checks GitHub Actions workflows `^\.github/workflows/.*\.ya?ml$` and sets `timeout-minutes: 30` to jobs not having `timeout-minutes`. 182 | Jobs with `timeout-minutes` aren't changed. 183 | You can specify the value of `timeout-minutes` with `-t` option. 184 | 185 | ```sh 186 | ghatm set -t 60 187 | ``` 188 | 189 | You can specify workflow files by positional arguments. 190 | 191 | ```sh 192 | ghatm set .github/workflows/test.yaml 193 | ``` 194 | 195 | ### Decide `timeout-minutes` based on each job's past execution times 196 | 197 | ```sh 198 | ghatm set -auto [-repo ] [-size ] 199 | ``` 200 | 201 | ghatm >= v0.3.2 [#68](https://github.com/suzuki-shunsuke/ghatm/issues/68) [#70](https://github.com/suzuki-shunsuke/ghatm/pull/70) 202 | 203 | > [!warning] 204 | > The feature doesn't support workflows using `workflow_call`. 205 | 206 | If the `-auto` option is used, ghatm calls GitHub API to get each job's past execution times and decide appropriate `timeout-minutes`. 207 | This feature requires a GitHub access token with the `actions:read` permission. 208 | You have to set the access token to the environment variable `GITHUB_TOKEN` or `GHATM_GITHUB_TOKEN`. 209 | 210 | GitHub API: 211 | 212 | - [List workflow runs for a workflow](https://docs.github.com/en/rest/actions/workflow-runs?apiVersion=2022-11-28#list-workflow-runs-for-a-workflow) 213 | - [List jobs for a workflow run](https://docs.github.com/en/rest/actions/workflow-jobs#list-jobs-for-a-workflow-run) 214 | 215 | ghatm takes 30 jobs by job to decide `timeout-minutes`. 216 | You can change the number of jobs by the `-size` option. 217 | 218 | ``` 219 | max(job execution times) + 10 220 | ``` 221 | 222 | ## Tips: Fix workflows by CI 223 | 224 | Using `ghatm` in CI, you can fix workflows automatically. 225 | When workflow files are added or changed in a pull request, you can run `ghatm` and commit and push changes to a feature branch. 226 | 227 | ## LICENSE 228 | 229 | [MIT](LICENSE) 230 | -------------------------------------------------------------------------------- /aqua/aqua-checksums.json: -------------------------------------------------------------------------------- 1 | { 2 | "checksums": [ 3 | { 4 | "id": "github_release/github.com/anchore/syft/v1.38.2/syft_1.38.2_darwin_amd64.tar.gz", 5 | "checksum": "D345DE5C7DCBD8E258D568CA40786768BB654CBA62F54CB8CA83C2C90A9D4422", 6 | "algorithm": "sha256" 7 | }, 8 | { 9 | "id": "github_release/github.com/anchore/syft/v1.38.2/syft_1.38.2_darwin_arm64.tar.gz", 10 | "checksum": "3B53CDFF2A1C66792329D91A914276E98EFBE548901978FE42B991EFC5DF90CF", 11 | "algorithm": "sha256" 12 | }, 13 | { 14 | "id": "github_release/github.com/anchore/syft/v1.38.2/syft_1.38.2_linux_amd64.tar.gz", 15 | "checksum": "6AA9A7B9E53C0F06E2D79FB24641CC1C856BB41702C282A577691F54BAD94996", 16 | "algorithm": "sha256" 17 | }, 18 | { 19 | "id": "github_release/github.com/anchore/syft/v1.38.2/syft_1.38.2_linux_arm64.tar.gz", 20 | "checksum": "CCD95833D4124F0E30925033908934978086727AF69585F015F28A4F355FDA1B", 21 | "algorithm": "sha256" 22 | }, 23 | { 24 | "id": "github_release/github.com/anchore/syft/v1.38.2/syft_1.38.2_windows_amd64.zip", 25 | "checksum": "74D57DACA2A9D08F0D470D3C4C11E8B98ED4A5EE5ABB70F1ED9D207D319FCF51", 26 | "algorithm": "sha256" 27 | }, 28 | { 29 | "id": "github_release/github.com/golangci/golangci-lint/v2.7.2/golangci-lint-2.7.2-darwin-amd64.tar.gz", 30 | "checksum": "6966554840A02229A14C52641BC38C2C7A14D396F4C59BA0C7C8BB0675CA25C9", 31 | "algorithm": "sha256" 32 | }, 33 | { 34 | "id": "github_release/github.com/golangci/golangci-lint/v2.7.2/golangci-lint-2.7.2-darwin-arm64.tar.gz", 35 | "checksum": "6CE86A00E22B3709F7B994838659C322FDC9EAE09E263DB50439AD4F6EC5785C", 36 | "algorithm": "sha256" 37 | }, 38 | { 39 | "id": "github_release/github.com/golangci/golangci-lint/v2.7.2/golangci-lint-2.7.2-linux-amd64.tar.gz", 40 | "checksum": "CE46A1F1D890E7B667259F70BB236297F5CF8791A9B6B98B41B283D93B5B6E88", 41 | "algorithm": "sha256" 42 | }, 43 | { 44 | "id": "github_release/github.com/golangci/golangci-lint/v2.7.2/golangci-lint-2.7.2-linux-arm64.tar.gz", 45 | "checksum": "7028E810837722683DAB679FB121336CFA303FECFF39DFE248E3E36BC18D941B", 46 | "algorithm": "sha256" 47 | }, 48 | { 49 | "id": "github_release/github.com/golangci/golangci-lint/v2.7.2/golangci-lint-2.7.2-windows-amd64.zip", 50 | "checksum": "D48F456944C5850CA408FEB0CAC186345F0A6D8CF5DC31875C8F63D3DFF5EE4C", 51 | "algorithm": "sha256" 52 | }, 53 | { 54 | "id": "github_release/github.com/golangci/golangci-lint/v2.7.2/golangci-lint-2.7.2-windows-arm64.zip", 55 | "checksum": "E5FC39E0F3FE817F093B5467BFC60D2A9D1292DE930B29322D2A1F8AFF2A3BBF", 56 | "algorithm": "sha256" 57 | }, 58 | { 59 | "id": "github_release/github.com/goreleaser/goreleaser/v2.13.1/goreleaser_Darwin_all.tar.gz", 60 | "checksum": "C7B5C26953E59B7E4B50913738C7FF2C371C95B5145BD0A2F93CFA5571D3BE97", 61 | "algorithm": "sha256" 62 | }, 63 | { 64 | "id": "github_release/github.com/goreleaser/goreleaser/v2.13.1/goreleaser_Linux_arm64.tar.gz", 65 | "checksum": "97051DE56BDCC4A76B2AA552FA85B633EBFFEA47B44BED85CD3580F12FC82651", 66 | "algorithm": "sha256" 67 | }, 68 | { 69 | "id": "github_release/github.com/goreleaser/goreleaser/v2.13.1/goreleaser_Linux_x86_64.tar.gz", 70 | "checksum": "04764528D7344BC5EFAE80EF62467480578A37DB0BB98EA2CEE185E04AEB1A7D", 71 | "algorithm": "sha256" 72 | }, 73 | { 74 | "id": "github_release/github.com/goreleaser/goreleaser/v2.13.1/goreleaser_Windows_arm64.zip", 75 | "checksum": "B4BAB00ED850E7E30054A462587FB7076A548CC137C5587694D2B8E5E65DFFA6", 76 | "algorithm": "sha256" 77 | }, 78 | { 79 | "id": "github_release/github.com/goreleaser/goreleaser/v2.13.1/goreleaser_Windows_x86_64.zip", 80 | "checksum": "25CB285AB0481A9456CA8EF8E39147D4CF018F0990BC560EFA3ED2A14E9D7DA7", 81 | "algorithm": "sha256" 82 | }, 83 | { 84 | "id": "github_release/github.com/int128/ghcp/v1.15.1/ghcp_darwin_amd64.zip", 85 | "checksum": "9FB8839EE08BC0D98973970F3FEF88322E49E8486AC3BD80DCE57DCEA97035E2", 86 | "algorithm": "sha256" 87 | }, 88 | { 89 | "id": "github_release/github.com/int128/ghcp/v1.15.1/ghcp_darwin_arm64.zip", 90 | "checksum": "5A923E288CE67B454CD1C7D5118EBF4028E9F1905DF3B1105AE8D05A1FE23A6E", 91 | "algorithm": "sha256" 92 | }, 93 | { 94 | "id": "github_release/github.com/int128/ghcp/v1.15.1/ghcp_linux_amd64.zip", 95 | "checksum": "C5BA74D0B2652A4375C4AE653B7C065024A619ADCFF40DEDBFDD10D40E287D3D", 96 | "algorithm": "sha256" 97 | }, 98 | { 99 | "id": "github_release/github.com/int128/ghcp/v1.15.1/ghcp_linux_arm64.zip", 100 | "checksum": "801DB65A3EA5148C7A89F5201F8992963F2592FC17ED5FD88E4D996BAD9542B2", 101 | "algorithm": "sha256" 102 | }, 103 | { 104 | "id": "github_release/github.com/int128/ghcp/v1.15.1/ghcp_windows_amd64.zip", 105 | "checksum": "A7673944CEFAA9955A3FEFA5DDF5B03C4CCEC2F7E5F0D14D2707804AF88BA22B", 106 | "algorithm": "sha256" 107 | }, 108 | { 109 | "id": "github_release/github.com/reviewdog/reviewdog/v0.21.0/reviewdog_0.21.0_Darwin_arm64.tar.gz", 110 | "checksum": "C28DEF83AF6C5AA8728D6D18160546AFD3E5A219117715A2C6C023BD16F14D10", 111 | "algorithm": "sha256" 112 | }, 113 | { 114 | "id": "github_release/github.com/reviewdog/reviewdog/v0.21.0/reviewdog_0.21.0_Darwin_x86_64.tar.gz", 115 | "checksum": "9BAADB110C87F22C55688CF4A966ACCE3006C7A4A962732D6C8B45234C454C6E", 116 | "algorithm": "sha256" 117 | }, 118 | { 119 | "id": "github_release/github.com/reviewdog/reviewdog/v0.21.0/reviewdog_0.21.0_Linux_arm64.tar.gz", 120 | "checksum": "B6AFF657B39E9267A258E8FA66D616F7221AEC5975D0251DAC76043BAD0FA177", 121 | "algorithm": "sha256" 122 | }, 123 | { 124 | "id": "github_release/github.com/reviewdog/reviewdog/v0.21.0/reviewdog_0.21.0_Linux_x86_64.tar.gz", 125 | "checksum": "AD5CE7D5FFA52AAA7EC8710A8FA764181B6CECAAB843CC791E1CCE1680381569", 126 | "algorithm": "sha256" 127 | }, 128 | { 129 | "id": "github_release/github.com/reviewdog/reviewdog/v0.21.0/reviewdog_0.21.0_Windows_arm64.tar.gz", 130 | "checksum": "72ABE9907454C5697777CFFF1D0D03DB8F5A9FD6950C609CA397A90D41AB65D7", 131 | "algorithm": "sha256" 132 | }, 133 | { 134 | "id": "github_release/github.com/reviewdog/reviewdog/v0.21.0/reviewdog_0.21.0_Windows_x86_64.tar.gz", 135 | "checksum": "97C733E492DEC1FD83B9342C25A384D5AB6EBFA72B6978346E9A436CAD1853F6", 136 | "algorithm": "sha256" 137 | }, 138 | { 139 | "id": "github_release/github.com/rhysd/actionlint/v1.7.9/actionlint_1.7.9_darwin_amd64.tar.gz", 140 | "checksum": "F89A910E90E536F60DF7C504160247DB01DD67CAB6F08C064C1C397B76C91A79", 141 | "algorithm": "sha256" 142 | }, 143 | { 144 | "id": "github_release/github.com/rhysd/actionlint/v1.7.9/actionlint_1.7.9_darwin_arm64.tar.gz", 145 | "checksum": "855E49E823FC68C6371FD6967E359CDE11912D8D44FED343283C8E6E943BD789", 146 | "algorithm": "sha256" 147 | }, 148 | { 149 | "id": "github_release/github.com/rhysd/actionlint/v1.7.9/actionlint_1.7.9_linux_amd64.tar.gz", 150 | "checksum": "233B280D05E100837F4AF1433C7B40A5DCB306E3AA68FB4F17F8A7F45A7DF7B4", 151 | "algorithm": "sha256" 152 | }, 153 | { 154 | "id": "github_release/github.com/rhysd/actionlint/v1.7.9/actionlint_1.7.9_linux_arm64.tar.gz", 155 | "checksum": "6B82A3B8C808BF1BCD39A95ACED22FC1A026EEF08EDE410F81E274AF8DEADBBC", 156 | "algorithm": "sha256" 157 | }, 158 | { 159 | "id": "github_release/github.com/rhysd/actionlint/v1.7.9/actionlint_1.7.9_windows_amd64.zip", 160 | "checksum": "7C8B10A93723838BC3533F6E1886D868FDBB109B81606EBE6D1A533D11D8E978", 161 | "algorithm": "sha256" 162 | }, 163 | { 164 | "id": "github_release/github.com/rhysd/actionlint/v1.7.9/actionlint_1.7.9_windows_arm64.zip", 165 | "checksum": "7ACA9BF09EEDF0A743E08C7CB9F1712467A7324A9342A029AE4536FB4BE95C25", 166 | "algorithm": "sha256" 167 | }, 168 | { 169 | "id": "github_release/github.com/sigstore/cosign/v3.0.3/cosign-darwin-amd64", 170 | "checksum": "6C75981E85E081A73F0B4087F58E0AD5FD4712C71B37FA0B6AD774C1F965BAFA", 171 | "algorithm": "sha256" 172 | }, 173 | { 174 | "id": "github_release/github.com/sigstore/cosign/v3.0.3/cosign-darwin-arm64", 175 | "checksum": "38349E45A8BB0D1ED3A7AFFB8BDD2E9D597CEE08B6800C395A926B4D9ADB84D2", 176 | "algorithm": "sha256" 177 | }, 178 | { 179 | "id": "github_release/github.com/sigstore/cosign/v3.0.3/cosign-linux-amd64", 180 | "checksum": "052363A0E23E2E7ED53641351B8B420918E7E08F9C1D8A42A3DD3877A78A2E10", 181 | "algorithm": "sha256" 182 | }, 183 | { 184 | "id": "github_release/github.com/sigstore/cosign/v3.0.3/cosign-linux-arm64", 185 | "checksum": "81398231362031E3C7AFD6A7508C57049460CD7E02736F1EBE89A452102253E5", 186 | "algorithm": "sha256" 187 | }, 188 | { 189 | "id": "github_release/github.com/sigstore/cosign/v3.0.3/cosign-windows-amd64.exe", 190 | "checksum": "2593655025B52B5B1C99E43464459B645A3ACBE5D4A5A9F3A766E77BEEC5A441", 191 | "algorithm": "sha256" 192 | }, 193 | { 194 | "id": "github_release/github.com/suzuki-shunsuke/cmdx/v2.0.2/cmdx_darwin_amd64.tar.gz", 195 | "checksum": "768B8517666A15D25A6870307231416016FC1165F8A1C1743B6AACDBAC7A5FAC", 196 | "algorithm": "sha256" 197 | }, 198 | { 199 | "id": "github_release/github.com/suzuki-shunsuke/cmdx/v2.0.2/cmdx_darwin_arm64.tar.gz", 200 | "checksum": "FBD7DADDBB65ABD0DE5C6B898F2219588C7D1A71DF6808137D0A628858E7777B", 201 | "algorithm": "sha256" 202 | }, 203 | { 204 | "id": "github_release/github.com/suzuki-shunsuke/cmdx/v2.0.2/cmdx_linux_amd64.tar.gz", 205 | "checksum": "40BC7B5F472211B22C4786D55F6859FA8093F1A373FF40A2DCCD29BD3D11CF96", 206 | "algorithm": "sha256" 207 | }, 208 | { 209 | "id": "github_release/github.com/suzuki-shunsuke/cmdx/v2.0.2/cmdx_linux_arm64.tar.gz", 210 | "checksum": "691EB4CC3929A5E065F7C2F977CEE8306D817CB0F8DE9D5B4B4ED38C027CEC41", 211 | "algorithm": "sha256" 212 | }, 213 | { 214 | "id": "github_release/github.com/suzuki-shunsuke/cmdx/v2.0.2/cmdx_windows_amd64.zip", 215 | "checksum": "4452010897556935E3F94A11AF2B2889563E05073A6DEA72FCF40B83B7F4AE5B", 216 | "algorithm": "sha256" 217 | }, 218 | { 219 | "id": "github_release/github.com/suzuki-shunsuke/cmdx/v2.0.2/cmdx_windows_arm64.zip", 220 | "checksum": "156D02F4E784E237B0661464D6FF76D6C4EFC4E01F858F8A9734364CD41BC98E", 221 | "algorithm": "sha256" 222 | }, 223 | { 224 | "id": "github_release/github.com/suzuki-shunsuke/ghatm/v1.0.0/ghatm_darwin_amd64.tar.gz", 225 | "checksum": "1D9EEF823CBC2A7E8789C0A1511896292A831D477670B9B4180A9A9DB817EBAD", 226 | "algorithm": "sha256" 227 | }, 228 | { 229 | "id": "github_release/github.com/suzuki-shunsuke/ghatm/v1.0.0/ghatm_darwin_arm64.tar.gz", 230 | "checksum": "63204EB557D71E8CF5AF739A82444CF231581F067E36903AECE3E172544D16A8", 231 | "algorithm": "sha256" 232 | }, 233 | { 234 | "id": "github_release/github.com/suzuki-shunsuke/ghatm/v1.0.0/ghatm_linux_amd64.tar.gz", 235 | "checksum": "22195C41BC439958E3983AD1CF6F7B2D3ED8A0685DF4B15BFF5B2BBFB38118ED", 236 | "algorithm": "sha256" 237 | }, 238 | { 239 | "id": "github_release/github.com/suzuki-shunsuke/ghatm/v1.0.0/ghatm_linux_arm64.tar.gz", 240 | "checksum": "92368685124A3A7DB0E59C2C56E9AB1AF3EAF636A8F891C86684F5B6010ED5E5", 241 | "algorithm": "sha256" 242 | }, 243 | { 244 | "id": "github_release/github.com/suzuki-shunsuke/ghatm/v1.0.0/ghatm_windows_amd64.zip", 245 | "checksum": "B195C25AB335729EAF63FE9C40185091E9E87BF709C7A487E7B258096BE2B63A", 246 | "algorithm": "sha256" 247 | }, 248 | { 249 | "id": "github_release/github.com/suzuki-shunsuke/ghatm/v1.0.0/ghatm_windows_arm64.zip", 250 | "checksum": "58721135592547169328CCDAA60B12D20E14575AFE2E2760571BFB19CD9C664D", 251 | "algorithm": "sha256" 252 | }, 253 | { 254 | "id": "registries/github_content/github.com/aquaproj/aqua-registry/v4.446.0/registry.yaml", 255 | "checksum": "F89F41662F54892E9E06168596845AA2C84865B6ED9782B527C47622BE3035E3C126C51D80BFFF76B41C4150D41FF1F70F6B521EA52FB6B4933D6170CACEF9F3", 256 | "algorithm": "sha512" 257 | } 258 | ] 259 | } 260 | --------------------------------------------------------------------------------