├── .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 | [](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 |
--------------------------------------------------------------------------------