├── .checkov.yaml ├── .depguard.yml ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md └── workflows │ ├── ci.yml │ ├── codeql-analysis.yml │ ├── pr.yml │ ├── release.yml │ └── stale.yml ├── .gitignore ├── .goreleaser.yml ├── .markdownlint.json ├── .pre-commit-config.yaml ├── .pre-commit-hooks.yaml ├── CONTRIBUTING.md ├── Dockerfile ├── LICENSE ├── Makefile ├── README.md ├── SECURITY.md ├── bump.ps1 ├── bump.sh ├── entrypoint.sh ├── ghat.png ├── go.mod ├── go.sum ├── main.go ├── set-version.sh └── src ├── core ├── action.go ├── action_test.go ├── error.go ├── error_test.go ├── filter.go ├── filter_test.go ├── gha.go ├── gha_test.go ├── modules.go ├── modules_test.go ├── pre-commit.go ├── pre-commit_test.go ├── registry.go ├── registry_test.go ├── testdata │ ├── faulty │ │ └── .github │ │ │ └── workflows │ │ │ └── test.yml │ ├── files │ │ ├── ci.yml │ │ └── module.tf │ ├── gha │ │ └── .github │ │ │ └── workflows │ │ │ └── test.yml │ ├── modules │ │ ├── depth │ │ │ └── module.tf │ │ ├── github-git │ │ │ └── module.tf │ │ ├── registry │ │ │ ├── module.git.tf │ │ │ ├── module.tf │ │ │ ├── nomoduleshere.tfvars │ │ │ └── subdir │ │ │ │ └── catch.tf │ │ ├── subdir │ │ │ └── module.tf │ │ └── version │ │ │ ├── gt │ │ │ └── module.tf │ │ │ └── range │ │ │ └── module.range.tf │ ├── noworkflows │ │ └── placeholder │ └── noworkflowswithdir │ │ └── .github │ │ └── placeholder └── types.go └── version └── version.go /.checkov.yaml: -------------------------------------------------------------------------------- 1 | block-list-secret-scan: [ ] 2 | branch: master 3 | directory: 4 | - . 5 | download-external-modules: false 6 | evaluate-variables: true 7 | external-modules-download-path: .external_modules 8 | framework: 9 | - secrets 10 | - github_configuration 11 | - github_actions 12 | - json 13 | - yaml 14 | - sca_package 15 | - sca_image 16 | mask: [ ] 17 | secrets-history-timeout: 12h 18 | secrets-scan-file-type: [ ] 19 | skip-path: 20 | - terraform 21 | - venv 22 | summary-position: top 23 | -------------------------------------------------------------------------------- /.depguard.yml: -------------------------------------------------------------------------------- 1 | Main: 2 | files: 3 | - $all 4 | - "!$test" 5 | allow: 6 | - $gostd 7 | - github.com/aws/aws-sdk-go-v2/aws 8 | - github.com/aws/aws-sdk-go-v2/config 9 | - github.com/aws/aws-sdk-go-v2/service/iam 10 | - github.com/awslabs/goformation/v7/cloudformation 11 | - github.com/awslabs/goformation/v7/cloudformation/tags 12 | - github.com/go-git/go-git/v5 13 | - github.com/go-git/go-git/v5/plumbing 14 | - github.com/gobeam/stringy 15 | - github.com/hashicorp/hc-install/product 16 | - github.com/hashicorp/hc-install/releases 17 | - github.com/hashicorp/terraform-exec/tfexec 18 | - github.com/jameswoolfenden/ghat/src 19 | - github.com/rs/zerolog 20 | - github.com/rs/zerolog/log 21 | - github.com/urfave/cli/v2 22 | - sato/src/arm 23 | - sato/src/cf 24 | - sato/src/see 25 | deny: 26 | Test: 27 | files: 28 | - $test 29 | allow: 30 | - $gostd 31 | deny: 32 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 16 | 1. Go to '...' 17 | 2. Click on '....' 18 | 3. Scroll down to '....' 19 | 4. See error 20 | 21 | **Expected behavior** 22 | A clear and concise description of what you expected to happen. 23 | 24 | **Screenshots** 25 | If applicable, add screenshots to help explain your problem. 26 | 27 | **Desktop (please complete the following information):** 28 | 29 | - OS: [e.g. iOS] 30 | - Browser [e.g. chrome, safari] 31 | - Version [e.g. 22] 32 | 33 | **Smartphone (please complete the following information):** 34 | 35 | - Device: [e.g. iPhone6] 36 | - OS: [e.g. iOS8.1] 37 | - Browser [e.g. stock browser, safari] 38 | - Version [e.g. 22] 39 | 40 | **Additional context** 41 | Add any other context about the problem here. 42 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. 12 | Ex. I'm always frustrated when [...] 13 | 14 | **Describe the solution you'd like** 15 | A clear and concise description of what you want to happen. 16 | 17 | **Describe alternatives you've considered** 18 | A clear and concise description of any alternative solutions or features you've considered. 19 | 20 | **Additional context** 21 | Add any other context or screenshots about the feature request here. 22 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | branches: 4 | - master 5 | 6 | name: CI 7 | permissions: read-all 8 | env: 9 | GITHUB_TOKEN: ${{ github.token }} 10 | jobs: 11 | test: 12 | ## We want to define a strategy for our job 13 | strategy: 14 | ## this will contain a matrix of all the combinations 15 | ## we wish to test again: 16 | matrix: 17 | go-version: [ 1.24.x ] 18 | platform: [ ubuntu-latest, macos-latest, windows-latest ] 19 | 20 | ## Defines the platform for each test run 21 | runs-on: ${{ matrix.platform }} 22 | 23 | ## the steps that will be run through for each version and platform 24 | ## combination 25 | steps: 26 | ## sets up go based on the version 27 | - name: Install Go 28 | uses: actions/setup-go@0aaccfd150d50ccaeb58ebd88d36e91967a5f35b # v5.4.0 29 | with: 30 | go-version: ${{ matrix.go-version }} 31 | 32 | ## checks out our code locally, so we can work with the files 33 | - name: Checkout code 34 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 35 | 36 | ## runs go test ./... 37 | - name: Build 38 | run: go build ./... 39 | 40 | ## runs go test ./... 41 | - name: Test 42 | run: go test ./... -coverprofile=./cover.out 43 | 44 | - name: Upload coverage reports to Codecov 45 | uses: codecov/codecov-action@1f60566a86da84c4b4b64c17662a90de97fbb8d7 # v5.4.2 46 | env: 47 | CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} 48 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: [ "master" ] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: [ "master" ] 20 | schedule: 21 | - cron: '36 4 * * 0' 22 | permissions: read-all 23 | 24 | jobs: 25 | analyze: 26 | name: Analyze 27 | runs-on: ubuntu-latest 28 | permissions: 29 | actions: read 30 | contents: read 31 | security-events: write 32 | 33 | strategy: 34 | fail-fast: false 35 | matrix: 36 | language: [ 'go' ] 37 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] 38 | # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support 39 | 40 | steps: 41 | - name: Checkout repository 42 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 43 | 44 | # Initializes the CodeQL tools for scanning. 45 | - name: Initialize CodeQL 46 | uses: github/codeql-action/init@4c3e5362829f0b0bb62ff5f6c938d7f95574c306 # codeql-bundle-v2.21.1 47 | with: 48 | languages: ${{ matrix.language }} 49 | # If you wish to specify custom queries, you can do so here or in a config file. 50 | # By default, queries listed here will override any specified in a config file. 51 | # Prefix the list here with "+" to use these queries and those in the config file. 52 | 53 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 54 | # If this step fails, then you should remove it and run the build manually (see below) 55 | - name: Autobuild 56 | uses: github/codeql-action/autobuild@4c3e5362829f0b0bb62ff5f6c938d7f95574c306 # codeql-bundle-v2.21.1 57 | 58 | # ℹ️ Command-line programs to run using the OS shell. 59 | # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun 60 | 61 | # If the Autobuild fails above, remove it and uncomment the following three lines. 62 | # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance. 63 | 64 | # - run: | 65 | # echo "Run, Build Application using script" 66 | # ./location_of_script_within_repo/buildscript.sh 67 | 68 | - name: Perform CodeQL Analysis 69 | uses: github/codeql-action/analyze@4c3e5362829f0b0bb62ff5f6c938d7f95574c306 # codeql-bundle-v2.21.1 70 | -------------------------------------------------------------------------------- /.github/workflows/pr.yml: -------------------------------------------------------------------------------- 1 | on: pull_request 2 | name: Pull request 3 | permissions: read-all 4 | env: 5 | GITHUB_TOKEN: ${{ github.token }} 6 | jobs: 7 | lint: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - name: Checkout code 11 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 12 | - name: Install Go 13 | uses: actions/setup-go@0aaccfd150d50ccaeb58ebd88d36e91967a5f35b # v5.4.0 14 | with: 15 | go-version: 1.24.x 16 | - name: Restore cache 17 | uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3 18 | with: 19 | path: ~/go/pkg/mod 20 | key: ${{ runner.os }}-go-mod-${{ hashFiles('**/go.sum') }} 21 | restore-keys: | 22 | ${{ runner.os }}-go-mod- 23 | - name: gofumpt 24 | uses: jameswoolfenden/auto-gofmt@99a3ed2b78b6c01d70db1740ba16d3dff60003df # v0.0.3 25 | test: 26 | strategy: 27 | matrix: 28 | go-version: [ 1.24.x ] 29 | platform: [ ubuntu-latest, macos-latest, windows-latest ] 30 | runs-on: ${{ matrix.platform }} 31 | steps: 32 | - name: Checkout code 33 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 34 | - name: Install Go 35 | uses: actions/setup-go@0aaccfd150d50ccaeb58ebd88d36e91967a5f35b # v5.4.0 36 | with: 37 | go-version: ${{ matrix.go-version }} 38 | - name: Restore cache 39 | uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3 40 | with: 41 | path: ~/go/pkg/mod 42 | key: ${{ runner.os }}-go-mod-${{ hashFiles('**/go.sum') }} 43 | restore-keys: | 44 | ${{ runner.os }}-go-mod- 45 | ## runs go test ./... 46 | - name: Build 47 | run: go build ./... 48 | 49 | ## runs go test ./... 50 | - name: Test 51 | run: go test ./... -coverprofile=./cover.out 52 | docs: 53 | runs-on: ubuntu-latest 54 | steps: 55 | - name: Checkout code 56 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 57 | - name: Install Go 58 | uses: actions/setup-go@0aaccfd150d50ccaeb58ebd88d36e91967a5f35b # v5.4.0 59 | with: 60 | go-version: 1.24.x 61 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Release 3 | on: 4 | push: 5 | tags: 6 | - v* 7 | permissions: read-all 8 | jobs: 9 | goreleaser: 10 | permissions: write-all 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Checkout 14 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 15 | - name: Unshallow 16 | run: git fetch --prune --unshallow 17 | - name: Set up Go 18 | uses: actions/setup-go@0aaccfd150d50ccaeb58ebd88d36e91967a5f35b # v5.4.0 19 | with: 20 | go-version: 1.24 21 | - name: Import GPG key 22 | id: import_gpg 23 | uses: crazy-max/ghaction-import-gpg@e89d40939c28e39f97cf32126055eeae86ba74ec # v6.3.0 24 | with: 25 | gpg_private_key: ${{ secrets.GPG_PRIVATE_KEY }} 26 | passphrase: ${{ secrets.PASSPHRASE }} 27 | - name: Run GoReleaser 28 | uses: goreleaser/goreleaser-action@9c156ee8a17a598857849441385a2041ef570552 # v6.3.0 29 | with: 30 | version: latest 31 | args: release --clean 32 | env: 33 | GPG_FINGERPRINT: ${{ steps.import_gpg.outputs.fingerprint }} 34 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 35 | GPG_PRIVATE_KEY: ${{ secrets.GPG_PRIVATE_KEY }} 36 | PASSPHRASE: ${{ secrets.PASSPHRASE }} 37 | HOMEBREW_TAP_GITHUB_TOKEN: ${{ secrets.HOMEBREW_TAP_GITHUB_TOKEN }} 38 | publish-dockerhub: 39 | permissions: write-all 40 | runs-on: ubuntu-latest 41 | needs: 42 | - goreleaser 43 | steps: 44 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 45 | - name: Publish to Registry 46 | uses: elgohr/Publish-Docker-Github-Action@82556589c08f584cb95411629a94e6c2b68b9b80 # v5 47 | with: 48 | name: jameswoolfenden/ghat 49 | username: ${{ secrets.DOCKERHUB_USERNAME }} 50 | password: ${{ secrets.DOCKERHUB_PASSWORD }} 51 | tags: "latest,${{ github.ref_name }}" 52 | - name: Update Docker Hub README 53 | uses: peter-evans/dockerhub-description@432a30c9e07499fd01da9f8a49f0faf9e0ca5b77 # v4.0.2 54 | with: 55 | username: ${{ secrets.DOCKERHUB_USERNAME }} 56 | password: ${{ secrets.DOCKERHUB_PASSWORD }} 57 | repository: jameswoolfenden/ghat 58 | short-description: ${{ github.event.repository.description }} 59 | readme-filepath: ./README.md 60 | update-scoop: 61 | permissions: write-all 62 | runs-on: ubuntu-latest 63 | needs: 64 | - goreleaser 65 | steps: 66 | - name: Repository Dispatch 67 | uses: peter-evans/repository-dispatch@ff45666b9427631e3450c54a1bcbee4d9ff4d7c0 # v3.0.0 68 | with: 69 | token: ${{ secrets.PAT }} 70 | repository: jameswoolfenden/scoop 71 | event-type: ghat 72 | client-payload: '{"ref": "${{ github.ref }}", "sha": "${{ github.sha }}"}' 73 | -------------------------------------------------------------------------------- /.github/workflows/stale.yml: -------------------------------------------------------------------------------- 1 | name: 'Stale' 2 | on: 3 | schedule: 4 | - cron: '30 1 * * *' 5 | workflow_dispatch: 6 | permissions: read-all 7 | 8 | jobs: 9 | stale: 10 | permissions: 11 | contents: write 12 | issues: write 13 | pull-requests: write 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/stale@5bef64f19d7facfb25b37b414482c7164d639639 # v9.1.0 17 | with: 18 | stale-issue-message: 'This issue is stale because it has been open 30 days with no activity. Remove stale label or comment or this will be closed in 5 days.' 19 | days-before-stale: 30 20 | days-before-close: 5 21 | enable-statistics: true 22 | exempt-issue-labels: enhancement 23 | exempt-pr-labels: enhancement 24 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ghat.exe 2 | .idea 3 | .terraform 4 | terraform.tfstate 5 | .terraform.lock.hcl 6 | .vscode/ 7 | ghat 8 | .pytest_cache/ 9 | *.backup 10 | __debug_bin.exe 11 | .DS_Store 12 | *.orig 13 | .terraform.tfstate.lock.info 14 | .ghat 15 | provider.azure.tf 16 | provider.azurerm.tf 17 | terraform-provider-* 18 | *.pem 19 | *.csr 20 | .destination 21 | tf.plan 22 | tf.json 23 | 24 | dist/ 25 | -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | # .goreleaser.yml 2 | 3 | # The lines below are called `modelines`. See `:help modeline` 4 | # Feel free to remove those if you don't want/need to use them. 5 | # yaml-language-server: $schema=https://goreleaser.com/static/schema.json 6 | # vim: set ts=2 sw=2 tw=0 fo=cnqoj 7 | 8 | version: 2 9 | before: 10 | hooks: 11 | - ./set-version.sh 12 | 13 | builds: 14 | - goos: 15 | - darwin 16 | - linux 17 | - windows 18 | goarch: 19 | - "386" 20 | - amd64 21 | - arm64 22 | goarm: 23 | - "7" 24 | ignore: 25 | - goarch: "386" 26 | goos: darwin 27 | archives: 28 | - format_overrides: 29 | - goos: windows 30 | format: zip 31 | 32 | brews: 33 | - name: ghat 34 | 35 | repository: 36 | owner: JamesWoolfenden 37 | name: homebrew-tap 38 | token: "{{ .Env.HOMEBREW_TAP_GITHUB_TOKEN }}" 39 | 40 | 41 | commit_author: 42 | name: "GitHub Action" 43 | email: action@github.com 44 | 45 | homepage: "https://github.com/JamesWoolfenden/ghat#readme" 46 | 47 | description: "Ghat is a tool for updating GHA dependencies" 48 | 49 | install: | 50 | bin.install "ghat" 51 | 52 | test: | 53 | system "#{bin}/ghat", "--help" 54 | -------------------------------------------------------------------------------- /.markdownlint.json: -------------------------------------------------------------------------------- 1 | { 2 | "MD013": false, 3 | "MD033": { 4 | "allowed_elements": [ 5 | "cloud" 6 | ] 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | default_language_version: 2 | python: python3.11 3 | repos: 4 | - hooks: 5 | - id: check-json 6 | - id: check-merge-conflict 7 | - id: trailing-whitespace 8 | - id: end-of-file-fixer 9 | - id: check-yaml 10 | exclude: examples/ 11 | - id: check-added-large-files 12 | - id: pretty-format-json 13 | args: 14 | - --autofix 15 | - id: detect-aws-credentials 16 | - id: detect-private-key 17 | repo: https://github.com/pre-commit/pre-commit-hooks 18 | rev: cef0300fd0fc4d2a87a85fa2093c6b283ea36f4b 19 | - hooks: 20 | - id: forbid-tabs 21 | exclude: binary|\.bin$|rego|\.rego$|go|\.go$ 22 | exclude_types: 23 | - python 24 | - javascript 25 | - dtd 26 | - markdown 27 | - makefile 28 | - xml 29 | repo: https://github.com/Lucas-C/pre-commit-hooks 30 | rev: a30f0d816e5062a67d87c8de753cfe499672b959 31 | - hooks: 32 | - id: shell-lint 33 | repo: https://github.com/jameswoolfenden/pre-commit-shell 34 | rev: 062f0b028ae65827e04f91c1e6738cfcbe9b337f 35 | - hooks: 36 | - id: markdownlint 37 | exclude: src/testdata|testdata 38 | repo: https://github.com/igorshubovych/markdownlint-cli 39 | rev: 586c3ea3f51230da42bab657c6a32e9e66c364f0 40 | - hooks: 41 | - id: terraform-fmt 42 | language_version: python3.11 43 | repo: https://github.com/jameswoolfenden/pre-commit 44 | rev: b00d945c0dce54f230a5d1cfb7d24e285396e1f2 45 | - hooks: 46 | - id: gofmt 47 | - id: goimports 48 | repo: https://github.com/gruntwork-io/pre-commit 49 | rev: 59fd8610ae21aaf8234f1ef17d43c3ccdee84d16 50 | - hooks: 51 | - id: go-test 52 | args: 53 | - ./... 54 | - id: go-mod-tidy 55 | - id: go-generate 56 | repo: https://github.com/syntaqx/git-hooks 57 | rev: a3b888f92cd5b40b270c9a9752181fdc1717cbe5 58 | - hooks: 59 | - id: golangci-lint 60 | repo: https://github.com/golangci/golangci-lint 61 | rev: 8c14421d29bd005dee63044d07aa897b7d1bf8b0 62 | - hooks: 63 | - id: checkov 64 | language_version: python3.11 65 | args: 66 | - -d 67 | - . 68 | repo: https://github.com/bridgecrewio/checkov 69 | rev: 3.2.408 70 | - hooks: 71 | - id: ghat-go 72 | name: ghat 73 | entry: ghat swot -d . --continue-on-error true 74 | language: golang 75 | types: 76 | - yaml 77 | always_run: true 78 | description: upgrade action dependencies 79 | - id: ghat-go-sift 80 | name: sift 81 | entry: ghat sift -d . 82 | language: golang 83 | types: 84 | - yaml 85 | always_run: true 86 | description: upgrade action dependencies 87 | repo: local 88 | - hooks: 89 | - id: validate-toml 90 | - id: no-go-testing 91 | - id: go-mod-tidy 92 | repo: https://github.com/dnephin/pre-commit-golang 93 | rev: fb24a639f7c938759fe56eeebbb7713b69d60494 94 | -------------------------------------------------------------------------------- /.pre-commit-hooks.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | # Build and run the Go binary from the repo 3 | # REQUIRES: Go v1.16+ installed 4 | - id: ghat-go 5 | name: ghat 6 | description: upgrade action dependencies 7 | language: golang 8 | entry: ghat swot -d . 9 | pass_filenames: false 10 | types: [ yaml ] 11 | 12 | - id: ghat-go-sift 13 | name: sift 14 | description: upgrade pre-commit dependencies 15 | language: golang 16 | entry: ghat sift -d . 17 | pass_filenames: false 18 | types: [ yaml ] 19 | 20 | # Build and run `ghat-docs` assuming it was installed manually 21 | # or via package manager 22 | # REQUIRES: ghat-docs to be installed and on the $PATH 23 | - id: ghat-system 24 | name: ghat 25 | description: upgrade action dependencies 26 | language: system 27 | entry: ghat swot -d . 28 | pass_filenames: false 29 | types: [ yaml ] 30 | 31 | - id: ghat-system-sift 32 | name: sift 33 | description: upgrade pre-commit dependencies 34 | language: system 35 | entry: ghat sift -d . 36 | pass_filenames: false 37 | types: [ yaml ] 38 | 39 | # Builds and runs the Docker image from the repo 40 | # REQUIRES: Docker installed 41 | - id: ghat-docker 42 | name: ghat 43 | description: upgrade action dependencies (via Docker build) 44 | language: docker 45 | entry: ghat swot -d . 46 | pass_filenames: false 47 | types: [ yaml ] 48 | 49 | - id: ghat-docker-sift 50 | name: sift 51 | description: upgrade pre-commit dependencies (via Docker build) 52 | language: docker 53 | entry: ghat sift -d . 54 | pass_filenames: false 55 | types: [ yaml ] 56 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | I'll be more than happy to get any contributions. 4 | I'm currently using the pre-commit framework, you should have that installed 5 | and running on the code you want to add. 6 | James 7 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM alpine:3.18.2 2 | 3 | RUN apk --no-cache add build-base git curl jq bash 4 | RUN curl -s https://api.github.com/repos/JamesWoolfenden/ghat/releases/latest | jq '.assets[] | select(.name | contains("linux_386")) | select(.content_type | contains("gzip")) | .browser_download_url' -r | awk '{print "curl -L -k " $0 " -o ./ghat.tar.gz"}' | sh 5 | RUN tar -xf ./ghat.tar.gz -C /usr/bin/ && rm ./ghat.tar.gz && chmod +x /usr/bin/ghat && echo 'alias ghat="/usr/bin/ghat"' >> ~/.bashrc 6 | COPY entrypoint.sh /entrypoint.sh 7 | 8 | # Code file to execute when the docker container starts up (`entrypoint.sh`) 9 | ENTRYPOINT ["/entrypoint.sh"] 10 | 11 | LABEL layer.0.author="JamesWoolfenden" 12 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright 2023 James Woolfenden 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: 2 | TEST?=$$(go list ./... | grep -v 'vendor'| grep -v 'scripts'| grep -v 'version') 3 | HOSTNAME=jameswoolfenden 4 | FULL_PKG_NAME=github.com/jameswoolfenden/ghat 5 | VERSION_PLACEHOLDER=version.ProviderVersion 6 | NAMESPACE=dev 7 | BINARY=ghat 8 | OS_ARCH=darwin_amd64 9 | TERRAFORM=./terraform/ 10 | TF_TEST=./terraform_test/ 11 | 12 | default: 13 | 14 | build: 15 | go build -o ${BINARY} 16 | 17 | release: 18 | GOOS=darwin GOARCH=amd64 go build -o ./bin/${BINARY}_${VERSION}_darwin_amd64 19 | GOOS=freebsd GOARCH=386 go build -o ./bin/${BINARY}_${VERSION}_freebsd_386 20 | GOOS=freebsd GOARCH=amd64 go build -o ./bin/${BINARY}_${VERSION}_freebsd_amd64 21 | GOOS=freebsd GOARCH=arm go build -o ./bin/${BINARY}_${VERSION}_freebsd_arm 22 | GOOS=linux GOARCH=386 go build -o ./bin/${BINARY}_${VERSION}_linux_386 23 | GOOS=linux GOARCH=amd64 go build -o ./bin/${BINARY}_${VERSION}_linux_amd64 24 | GOOS=linux GOARCH=arm go build -o ./bin/${BINARY}_${VERSION}_linux_arm 25 | GOOS=openbsd GOARCH=386 go build -o ./bin/${BINARY}_${VERSION}_openbsd_386 26 | GOOS=openbsd GOARCH=amd64 go build -o ./bin/${BINARY}_${VERSION}_openbsd_amd64 27 | GOOS=solaris GOARCH=amd64 go build -o ./bin/${BINARY}_${VERSION}_solaris_amd64 28 | GOOS=windows GOARCH=386 go build -o ./bin/${BINARY}_${VERSION}_windows_386 29 | GOOS=windows GOARCH=amd64 go build -o ./bin/${BINARY}_${VERSION}_windows_amd64 30 | 31 | test: 32 | go test $(TEST) || exit 1 33 | echo $(TEST) | xargs -t -n4 go test $(TESTARGS) -timeout=30s -parallel=4 34 | 35 | testacc: 36 | TF_ACC=1 go test $(TEST) -v $(TESTARGS) -timeout 120m 37 | 38 | 39 | destroy: 40 | cd $(TERRAFORM) && terraform destroy --auto-approve 41 | 42 | 43 | BIN=$(CURDIR)/bin 44 | $(BIN)/%: 45 | @echo "Installing tools from tools/tools.go" 46 | @cat tools/tools.go | grep _ | awk -F '"' '{print $$2}' | GOBIN=$(BIN) xargs -tI {} go install {} 47 | 48 | generate-docs: 49 | echo "does nowt" 50 | 51 | docs: 52 | 53 | 54 | vet: 55 | go vet ./... 56 | 57 | bump: 58 | git push 59 | $(eval VERSION=$(shell git describe --tags --abbrev=0 | awk -F. '{OFS="."; $$NF+=1; print $0}')) 60 | git tag -a $(VERSION) -m "new release" 61 | git push origin $(VERSION) 62 | 63 | psbump: 64 | git push 65 | powershell -command "./bump.ps1" 66 | 67 | update: 68 | go get -u 69 | go mod tidy 70 | pre-commit autoupdate 71 | 72 | lint: 73 | golangci-lint run --fix 74 | 75 | gci: 76 | gci -w . 77 | 78 | fmt: 79 | gofumpt -l -w . 80 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ghat 2 | 3 | ![alt text](ghat.png "ghat") 4 | 5 | [![Maintenance](https://img.shields.io/badge/Maintained%3F-yes-green.svg)](https://GitHub.com/jameswoolfenden/ghat/graphs/commit-activity) 6 | [![Build Status](https://github.com/JamesWoolfenden/ghat/workflows/CI/badge.svg?branch=master)](https://github.com/JamesWoolfenden/ghat) 7 | [![Latest Release](https://img.shields.io/github/release/JamesWoolfenden/ghat.svg)](https://github.com/JamesWoolfenden/ghat/releases/latest) 8 | [![GitHub tag (latest SemVer)](https://img.shields.io/github/tag/JamesWoolfenden/ghat.svg?label=latest)](https://github.com/JamesWoolfenden/ghat/releases/latest) 9 | ![Terraform Version](https://img.shields.io/badge/tf-%3E%3D0.14.0-blue.svg) 10 | [![pre-commit](https://img.shields.io/badge/pre--commit-enabled-brightgreen?logo=pre-commit&logoColor=white)](https://github.com/pre-commit/pre-commit) 11 | [![checkov](https://img.shields.io/badge/checkov-verified-brightgreen)](https://www.checkov.io/) 12 | [![Github All Releases](https://img.shields.io/github/downloads/jameswoolfenden/ghat/total.svg)](https://github.com/JamesWoolfenden/ghat/releases) 13 | [![codecov](https://codecov.io/gh/JamesWoolfenden/ghat/graph/badge.svg?token=P9V791WMRE)](https://codecov.io/gh/JamesWoolfenden/ghat) 14 | 15 | Ghat is a tool (GHAT) for updating dependencies in a GHA - GitHub Action, **managing Terraform Dependencies** and pre-commit configs. It replaces insecure mutable tags with immutable commit hashes as well as using the latest released version: 16 | 17 | ```yml 18 | ## sets up go based on the version 19 | - name: Install Go 20 | uses: actions/setup-go@v4.0.1 21 | with: 22 | go-version: ${{ matrix.go-version }} 23 | 24 | ## checks out our code locally, so we can work with the files 25 | - name: Checkout code 26 | uses: actions/checkout@v3.5.3 27 | ``` 28 | 29 | Becomes 30 | 31 | ```yml 32 | ## sets up go based on the version 33 | - name: Install Go 34 | uses: actions/setup-go@fac708d6674e30b6ba41289acaab6d4b75aa0753 # v4.0.1 35 | with: 36 | go-version: ${{ matrix.go-version }} 37 | 38 | ## checks out our code locally, so we can work with the files 39 | - name: Checkout code 40 | uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 41 | ``` 42 | 43 | Ghat will use your GitHub credentials, if available, from your environment using the environmental variables GITHUB_TOKEN or GITHUB_API, but it can also drop back to anonymous access, the drawback is that this is severely rate limited by gitHub. 44 | 45 | Ghat also manages Terraform modules, to give you the most secure reference, so: 46 | 47 | ```terraform 48 | module "ip" { 49 | source = "JamesWoolfenden/ip/http" 50 | version = "0.3.12" 51 | permissions = "pike" 52 | } 53 | ``` 54 | 55 | Becomes: 56 | 57 | ```terraform 58 | module "ip" { 59 | source = "git::https://github.com/JamesWoolfenden/terraform-http-ip.git?ref=a6cf071d14365133f48ed161812c14b00ad3c692" 60 | permissions = "pike" 61 | } 62 | 63 | ``` 64 | 65 | ## Table of Contents 66 | 67 | 68 | - [ghat](#ghat) 69 | - [Table of Contents](#table-of-contents) 70 | - [Install](#install) 71 | - [MacOS](#macos) 72 | - [Windows](#windows) 73 | - [Docker](#docker) 74 | - [Usage](#usage) 75 | - [swot](#swot) 76 | - [directory](#directory-scan) 77 | - [file](#file-scan) 78 | - [stable](#stable-releases) 79 | - [pre-commit](#pre-commit) 80 | - [swipe](#swipe) 81 | - [sift](#sift) 82 | 83 | 84 | 85 | ## Install 86 | 87 | Download the latest binary here: 88 | 89 | 90 | 91 | Install from code: 92 | 93 | - Clone repo 94 | - Run `go install` 95 | 96 | Install remotely: 97 | 98 | ```shell 99 | go install github.com/jameswoolfenden/ghat@latest 100 | ``` 101 | 102 | ### MacOS 103 | 104 | ```shell 105 | brew tap jameswoolfenden/homebrew-tap 106 | brew install jameswoolfenden/tap/ghat 107 | ``` 108 | 109 | ### Windows 110 | 111 | I'm now using Scoop to distribute releases, it's much quicker to update and easier to manage than previous methods, 112 | you can install scoop from . 113 | 114 | Add my scoop bucket: 115 | 116 | ```shell 117 | scoop bucket add iac https://github.com/JamesWoolfenden/scoop.git 118 | ``` 119 | 120 | Then you can install a tool: 121 | 122 | ```bash 123 | scoop install ghat 124 | ``` 125 | 126 | ### Docker 127 | 128 | ```shell 129 | docker pull jameswoolfenden/ghat 130 | docker run --tty --volume /local/path/to/repo:/repo jameswoolfenden/ghat swot -d /repo 131 | ``` 132 | 133 | 134 | 135 | ## Usage 136 | 137 | To authenticate the GitHub Api you should set up your GitHub Personal Access Token as the environment variable 138 | *GITHUB_API* or *GITHUB_TOKEN*, it will fall back to using anonymous if you don't but RATE LIMITS. 139 | 140 | ### swot 141 | 142 | #### Directory scan 143 | 144 | This will look for the .github/workflow folder and update all the files it finds there, and display a diff of the changes made to each file: 145 | 146 | ```bash 147 | $ghat swot -d . 148 | ``` 149 | 150 | #### File scan 151 | 152 | ```bash 153 | $ghat swot -f .\.github\workflows\ci.yml 154 | ``` 155 | 156 | #### Stable releases 157 | 158 | If you're concerned that the very latest release might be too fresh, and would rather have the latest from 2 weeks ago? 159 | I got you covered: 160 | 161 | ```bash 162 | $ghat swot -d . --stable 14 163 | ``` 164 | 165 | ### Swipe 166 | 167 | Updates Terraform modules to use secure module references, and displays a file diff: 168 | 169 | ```bash 170 | ghat swipe -f .\registry\module.git.tf -update 171 | _ _ 172 | __ _ | |_ __ _ | |_ 173 | / _` || ' \ / _` || _| 174 | \__, ||_||_|\__,_| \__| 175 | |___/ 176 | version: 9.9.9 177 | 1:42PM INF module source is git::https://github.com/terraform-aws-modules/terraform-aws-memory-db.git?depth=1 of type shallow and cannot be updated 178 | module "ip" { 179 | source = "git::https://github.com/JamesWoolfenden/ip/terraform-http" 180 | v-ip.git?rersion f= "aca5d0.4513.1698f2f564913cfcc3534780794c800" 181 | permissions = "pike" 182 | } 183 | ``` 184 | 185 | The update flag can be used to update the reference, the default behaviour is just to change the reference to a git bashed hash. 186 | 187 | ### sift 188 | 189 | Sift updates pre-commit configs with the latest hooks using hashes. 190 | Commands are similar, but only the directory is needed: 191 | 192 | ```shell 193 | ghat sift -d . 194 | ``` 195 | 196 | The flag dryrun is also supported. Example outcome display: 197 | 198 | ```yaml 199 | - hooks: 200 | - id: forbid-tabs 201 | exclude: binary|\.bin$|rego|\.rego$|go|\.go$ 202 | exclude_types: 203 | - python 204 | - javascript 205 | - dtd 206 | - markdown 207 | - makefile 208 | - xml 209 | repo: https://github.com/Lucas-C/pre-commit-hooks 210 | rev: 762c66ea96843b54b936fc680162ea67f85ec2d7 211 | ``` 212 | 213 | ## Help 214 | 215 | ```bash 216 | ghat --help 217 | _ _ 218 | __ _ | |_ __ _ | |_ 219 | / _` || ' \ / _` || _| 220 | \__, ||_||_|\__,_| \__| 221 | |___/ 222 | version: v0.1.1 223 | NAME: 224 | ghat - Update GHA dependencies 225 | 226 | USAGE: 227 | ghat [global options] command [command options] [arguments...] 228 | 229 | VERSION: 230 | v0.1.1 231 | 232 | AUTHOR: 233 | James Woolfenden 234 | 235 | COMMANDS: 236 | sift, p updates pre-commit version with hashes 237 | swipe, w updates Terraform module versions with versioned hashes 238 | swot, a updates GHA versions for hashes 239 | version, v Outputs the application version 240 | help, h Shows a list of commands or help for one command 241 | 242 | GLOBAL OPTIONS: 243 | --help, -h show help 244 | --version, -v print the version 245 | 246 | COPYRIGHT: 247 | James Woolfenden 248 | ``` 249 | 250 | ### pre-commit 251 | 252 | I've added a number of pre-commit hooks to this repo that will update your build configs, 253 | update .pre-commit-config.yaml 254 | 255 | ```yaml 256 | - repo: https://github.com/JamesWoolfenden/ghat/actions 257 | rev: v0.0.10 258 | hooks: 259 | - id: ghat-go 260 | name: ghat 261 | description: upgrade action dependencies 262 | language: golang 263 | entry: ghat swot -d . 264 | pass_filenames: false 265 | always_run: true 266 | types: [ yaml ] 267 | 268 | ``` 269 | 270 | ## Building 271 | 272 | ```shell 273 | go build 274 | ``` 275 | 276 | or 277 | 278 | ```Make 279 | Make build 280 | ``` 281 | 282 | ## Extending 283 | 284 | Log an issue, a pr or an email to jim.wolf @ duck.com. 285 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Supported Versions 4 | 5 | I'm only supporting the latest version, and required changes will be releases ASAP. 6 | 7 | ## Reporting a Vulnerability 8 | 9 | Contact me James Woolfenden at gmail dot com. 10 | -------------------------------------------------------------------------------- /bump.ps1: -------------------------------------------------------------------------------- 1 | param( 2 | [Parameter(Mandatory = $false)] 3 | [ValidateNotNullOrEmpty()] 4 | [string]$message = "new release" 5 | ) 6 | 7 | $versionPattern = '^\d+\.\d+\.\d+$' 8 | $version = $null 9 | 10 | try 11 | { 12 | $version = $( git describe --tags --abbrev=0 ) -replace "v" 13 | if ($version -notmatch $versionPattern) 14 | { 15 | Write-Error "Invalid version format $version. Expected: x.y.z" 16 | exit 1 17 | } 18 | 19 | $splitter = $version.split(".") 20 | $build = [int]($splitter[2]) + 1 21 | [string]$newVersion = $splitter[0] + "." + $splitter[1] + "." + $build.ToString() 22 | 23 | if ([version]$newVersion -le [version]$version) 24 | { 25 | Write-Error "New version must be greater than current version" 26 | exit 1 27 | } 28 | 29 | Write-Host "Current version: $version" 30 | Write-Host "New version: $newVersion" 31 | Write-Host "Creating new tag..." 32 | 33 | git tag -a v$newVersion -m "$message" 34 | git push origin v$newVersion 35 | } 36 | catch 37 | { 38 | Write-Error "An error occurred: $_" 39 | exit 1 40 | } 41 | -------------------------------------------------------------------------------- /bump.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Parameters 4 | message="${1:-new release}" 5 | 6 | # Version pattern 7 | versionPattern='^[0-9]+\.[0-9]+\.[0-9]+$' 8 | version='' 9 | 10 | # Get the current version 11 | version=$(git describe --tags --abbrev=0 2>/dev/null) 12 | version=${version//v} 13 | if [[ ! $version =~ $versionPattern ]]; then 14 | echo "Invalid version format. Expected: x.y.z" 15 | exit 1 16 | fi 17 | 18 | # Split the version and increment the build number 19 | IFS='.' read -r major minor build <<< "$version" 20 | newBuild=$((build + 1)) 21 | newVersion="$major.$minor.$newBuild" 22 | 23 | if [[ ! "$newVersion" > "$version" ]]; then 24 | echo "New version must be greater than current version" 25 | exit 1 26 | fi 27 | 28 | # Output the current and new version 29 | echo "Current version: $version" 30 | echo "New version: $newVersion" 31 | echo "Creating new tag..." 32 | 33 | # Create a new tag and push it 34 | git tag -a "v$newVersion" -m "$message" 35 | git push origin "v$newVersion" 36 | -------------------------------------------------------------------------------- /entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Leverage the default env variables as described in: 4 | # https://docs.github.com/en/actions/reference/environment-variables#default-environment-variables 5 | if [[ $GITHUB_ACTIONS != "true" ]] 6 | then 7 | /usr/bin/ghat "$@" 8 | exit $? 9 | fi 10 | 11 | flags="" 12 | 13 | echo "running command:" 14 | echo ghat swot -f "$INPUT_FILE" "$flags" 15 | 16 | /usr/bin/ghat swot -f "$INPUT_FILE" "$flags" 17 | export ghat_EXIT_CODE=$? 18 | -------------------------------------------------------------------------------- /ghat.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JamesWoolfenden/ghat/4a0e6114a4d1437cc7b8493694020944a098280d/ghat.png -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/jameswoolfenden/ghat 2 | 3 | go 1.24.1 4 | 5 | require ( 6 | github.com/go-git/go-git/v5 v5.16.0 7 | github.com/hashicorp/hcl/v2 v2.23.0 8 | github.com/rs/zerolog v1.34.0 9 | github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 10 | github.com/urfave/cli/v2 v2.27.6 11 | github.com/zclconf/go-cty v1.16.2 12 | golang.org/x/mod v0.24.0 13 | gopkg.in/yaml.v3 v3.0.1 14 | moul.io/banner v1.0.1 15 | ) 16 | 17 | require ( 18 | dario.cat/mergo v1.0.1 // indirect 19 | github.com/Microsoft/go-winio v0.6.2 // indirect 20 | github.com/ProtonMail/go-crypto v1.2.0 // indirect 21 | github.com/agext/levenshtein v1.2.3 // indirect 22 | github.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect 23 | github.com/cloudflare/circl v1.6.1 // indirect 24 | github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect 25 | github.com/cyphar/filepath-securejoin v0.4.1 // indirect 26 | github.com/emirpasic/gods v1.18.1 // indirect 27 | github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect 28 | github.com/go-git/go-billy/v5 v5.6.2 // indirect 29 | github.com/go-test/deep v1.1.1 // indirect 30 | github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect 31 | github.com/google/go-cmp v0.7.0 // indirect 32 | github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect 33 | github.com/kevinburke/ssh_config v1.2.0 // indirect 34 | github.com/mattn/go-colorable v0.1.14 // indirect 35 | github.com/mattn/go-isatty v0.0.20 // indirect 36 | github.com/mitchellh/go-wordwrap v1.0.1 // indirect 37 | github.com/pjbgf/sha1cd v0.3.2 // indirect 38 | github.com/russross/blackfriday/v2 v2.1.0 // indirect 39 | github.com/skeema/knownhosts v1.3.1 // indirect 40 | github.com/xanzy/ssh-agent v0.3.3 // indirect 41 | github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect 42 | golang.org/x/crypto v0.37.0 // indirect 43 | golang.org/x/net v0.39.0 // indirect 44 | golang.org/x/sync v0.13.0 // indirect 45 | golang.org/x/sys v0.32.0 // indirect 46 | golang.org/x/text v0.24.0 // indirect 47 | golang.org/x/tools v0.32.0 // indirect 48 | gopkg.in/warnings.v0 v0.1.2 // indirect 49 | ) 50 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | dario.cat/mergo v1.0.1 h1:Ra4+bf83h2ztPIQYNP99R6m+Y7KfnARDfID+a+vLl4s= 2 | dario.cat/mergo v1.0.1/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= 3 | github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY= 4 | github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= 5 | github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= 6 | github.com/ProtonMail/go-crypto v1.2.0 h1:+PhXXn4SPGd+qk76TlEePBfOfivE0zkWFenhGhFLzWs= 7 | github.com/ProtonMail/go-crypto v1.2.0/go.mod h1:9whxjD8Rbs29b4XWbB8irEcE8KHMqaR2e7GWU1R+/PE= 8 | github.com/agext/levenshtein v1.2.3 h1:YB2fHEn0UJagG8T1rrWknE3ZQzWM06O8AMAatNn7lmo= 9 | github.com/agext/levenshtein v1.2.3/go.mod h1:JEDfjyjHDjOF/1e4FlBE/PkbqA9OfWu2ki2W0IB5558= 10 | github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= 11 | github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= 12 | github.com/apparentlymart/go-textseg/v15 v15.0.0 h1:uYvfpb3DyLSCGWnctWKGj857c6ew1u1fNQOlOtuGxQY= 13 | github.com/apparentlymart/go-textseg/v15 v15.0.0/go.mod h1:K8XmNZdhEBkdlyDdvbmmsvpAG721bKi0joRfFdHIWJ4= 14 | github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= 15 | github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= 16 | github.com/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0= 17 | github.com/cloudflare/circl v1.6.1/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs= 18 | github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= 19 | github.com/cpuguy83/go-md2man/v2 v2.0.7 h1:zbFlGlXEAKlwXpmvle3d8Oe3YnkKIK4xSRTd3sHPnBo= 20 | github.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= 21 | github.com/cyphar/filepath-securejoin v0.4.1 h1:JyxxyPEaktOD+GAnqIqTf9A8tHyAG22rowi7HkoSU1s= 22 | github.com/cyphar/filepath-securejoin v0.4.1/go.mod h1:Sdj7gXlvMcPZsbhwhQ33GguGLDGQL7h7bg04C/+u9jI= 23 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 24 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 25 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 26 | github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o= 27 | github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE= 28 | github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= 29 | github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= 30 | github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c= 31 | github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU= 32 | github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI= 33 | github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic= 34 | github.com/go-git/go-billy/v5 v5.6.2 h1:6Q86EsPXMa7c3YZ3aLAQsMA0VlWmy43r6FHqa/UNbRM= 35 | github.com/go-git/go-billy/v5 v5.6.2/go.mod h1:rcFC2rAsp/erv7CMz9GczHcuD0D32fWzH+MJAU+jaUU= 36 | github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4= 37 | github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII= 38 | github.com/go-git/go-git/v5 v5.16.0 h1:k3kuOEpkc0DeY7xlL6NaaNg39xdgQbtH5mwCafHO9AQ= 39 | github.com/go-git/go-git/v5 v5.16.0/go.mod h1:4Ge4alE/5gPs30F2H1esi2gPd69R0C39lolkucHBOp8= 40 | github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U= 41 | github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= 42 | github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= 43 | github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= 44 | github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw= 45 | github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= 46 | github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 47 | github.com/hashicorp/hcl/v2 v2.23.0 h1:Fphj1/gCylPxHutVSEOf2fBOh1VE4AuLV7+kbJf3qos= 48 | github.com/hashicorp/hcl/v2 v2.23.0/go.mod h1:62ZYHrXgPoX8xBnzl8QzbWq4dyDsDtfCRgIq1rbJEvA= 49 | github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A= 50 | github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= 51 | github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4= 52 | github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM= 53 | github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= 54 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 55 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 56 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 57 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 58 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 59 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 60 | github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= 61 | github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= 62 | github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= 63 | github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= 64 | github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 65 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 66 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 67 | github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQflz0v0= 68 | github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0= 69 | github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k= 70 | github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY= 71 | github.com/pjbgf/sha1cd v0.3.2 h1:a9wb0bp1oC2TGwStyn0Umc/IGKQnEgF0vVaZ8QF8eo4= 72 | github.com/pjbgf/sha1cd v0.3.2/go.mod h1:zQWigSxVmsHEZow5qaLtPYxpcKMMQpa09ixqBxuCS6A= 73 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 74 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 75 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 76 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 77 | github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= 78 | github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= 79 | github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= 80 | github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY= 81 | github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ= 82 | github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= 83 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 84 | github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8= 85 | github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= 86 | github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= 87 | github.com/skeema/knownhosts v1.3.1 h1:X2osQ+RAjK76shCbvhHHHVl3ZlgDm8apHEHFqRjnBY8= 88 | github.com/skeema/knownhosts v1.3.1/go.mod h1:r7KTdC8l4uxWRyK2TpQZ/1o5HaSzh06ePQNxPwTcfiY= 89 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 90 | github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= 91 | github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= 92 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 93 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 94 | github.com/urfave/cli/v2 v2.27.6 h1:VdRdS98FNhKZ8/Az8B7MTyGQmpIr36O1EHybx/LaZ4g= 95 | github.com/urfave/cli/v2 v2.27.6/go.mod h1:3Sevf16NykTbInEnD0yKkjDAeZDS0A6bzhBH5hrMvTQ= 96 | github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM= 97 | github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw= 98 | github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 h1:gEOO8jv9F4OT7lGCjxCBTO/36wtF6j2nSip77qHd4x4= 99 | github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM= 100 | github.com/zclconf/go-cty v1.16.2 h1:LAJSwc3v81IRBZyUVQDUdZ7hs3SYs9jv0eZJDWHD/70= 101 | github.com/zclconf/go-cty v1.16.2/go.mod h1:VvMs5i0vgZdhYawQNq5kePSpLAoz8u1xvZgrPIxfnZE= 102 | github.com/zclconf/go-cty-debug v0.0.0-20240509010212-0d6042c53940 h1:4r45xpDWB6ZMSMNJFMOjqrGHynW3DIBuR2H9j0ug+Mo= 103 | github.com/zclconf/go-cty-debug v0.0.0-20240509010212-0d6042c53940/go.mod h1:CmBdvvj3nqzfzJ6nTCIwDTPZ56aVGvDrmztiO5g3qrM= 104 | golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= 105 | golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE= 106 | golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc= 107 | golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8= 108 | golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY= 109 | golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU= 110 | golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= 111 | golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= 112 | golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY= 113 | golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E= 114 | golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610= 115 | golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= 116 | golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 117 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 118 | golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 119 | golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 120 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 121 | golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 122 | golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 123 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 124 | golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 125 | golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= 126 | golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 127 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 128 | golang.org/x/term v0.31.0 h1:erwDkOK1Msy6offm1mOgvspSkslFnIGsFnxOKoufg3o= 129 | golang.org/x/term v0.31.0/go.mod h1:R4BeIy7D95HzImkxGkTW1UQTtP54tio2RyHz7PwK0aw= 130 | golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 131 | golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= 132 | golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= 133 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 134 | golang.org/x/tools v0.32.0 h1:Q7N1vhpkQv7ybVzLFtTjvQya2ewbwNDZzUgfXGqtMWU= 135 | golang.org/x/tools v0.32.0/go.mod h1:ZxrU41P/wAbZD8EDa6dDCa6XfpkhJ7HFMjHJXfBDu8s= 136 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 137 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 138 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= 139 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 140 | gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME= 141 | gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI= 142 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 143 | gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= 144 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 145 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 146 | moul.io/banner v1.0.1 h1:+WsemGLhj2pOajw2eR5VYjLhOIqs0XhIRYchzTyMLk0= 147 | moul.io/banner v1.0.1/go.mod h1:XwvIGKkhKRKyN1vIdmR5oaKQLIkMhkMqrsHpS94QzAU= 148 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "sort" 7 | "time" 8 | 9 | "github.com/jameswoolfenden/ghat/src/core" 10 | "github.com/jameswoolfenden/ghat/src/version" 11 | "github.com/rs/zerolog" 12 | "github.com/rs/zerolog/log" 13 | "github.com/urfave/cli/v2" 14 | "moul.io/banner" 15 | ) 16 | 17 | func main() { 18 | fmt.Println(banner.Inline("ghat")) 19 | fmt.Println("version:", version.Version) 20 | 21 | log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr}) 22 | 23 | var myFlags core.Flags 24 | 25 | app := &cli.App{ 26 | EnableBashCompletion: true, 27 | Copyright: "James Woolfenden", 28 | Flags: []cli.Flag{}, 29 | Commands: []*cli.Command{ 30 | { 31 | Name: "version", 32 | Aliases: []string{"v"}, 33 | Usage: "Outputs the application version", 34 | UsageText: "ghat version", 35 | Action: func(*cli.Context) error { 36 | fmt.Println(version.Version) 37 | 38 | return nil 39 | }, 40 | }, 41 | { 42 | Name: "swot", 43 | Aliases: []string{"a"}, 44 | Usage: "updates GHA versions for hashes", 45 | UsageText: "ghat swot", 46 | Action: func(*cli.Context) error { 47 | return myFlags.Action("swot") 48 | }, 49 | Flags: []cli.Flag{ 50 | &cli.StringFlag{ 51 | Name: "file", 52 | Aliases: []string{"f"}, 53 | Usage: "GHA file to parse", 54 | Destination: &myFlags.File, 55 | Category: "files", 56 | }, 57 | &cli.StringFlag{ 58 | Name: "directory", 59 | Aliases: []string{"d"}, 60 | Usage: "Destination to update GHAs", 61 | Value: ".", 62 | Destination: &myFlags.Directory, 63 | Category: "files", 64 | }, 65 | &cli.UintFlag{ 66 | Name: "stable", 67 | Aliases: []string{"s"}, 68 | Usage: "days to wait for stabilisation of release", 69 | Value: 0, 70 | Destination: &myFlags.Days, 71 | DefaultText: "0", 72 | Category: "delay", 73 | }, 74 | &cli.StringFlag{ 75 | Name: "token", 76 | Aliases: []string{"t"}, 77 | Usage: "Github PAT token", 78 | Destination: &myFlags.GitHubToken, 79 | Category: "authentication", 80 | EnvVars: []string{"GITHUB_TOKEN", "GITHUB_API"}, 81 | }, 82 | &cli.BoolFlag{ 83 | Name: "dry-run", 84 | Usage: "show but don't write changes", 85 | Destination: &myFlags.DryRun, 86 | Value: false, 87 | }, 88 | &cli.BoolFlag{ 89 | Name: "continue-on-error", 90 | Usage: "just keep going", 91 | Destination: &myFlags.ContinueOnError, 92 | Value: false, 93 | }, 94 | }, 95 | }, 96 | { 97 | Name: "swipe", 98 | Aliases: []string{"w"}, 99 | Usage: "updates Terraform module versions with versioned hashes", 100 | UsageText: "ghat swipe", 101 | Action: func(*cli.Context) error { 102 | return myFlags.Action("swipe") 103 | }, 104 | Flags: []cli.Flag{ 105 | &cli.StringFlag{ 106 | Name: "file", 107 | Aliases: []string{"f"}, 108 | Usage: "module file to parse", 109 | Destination: &myFlags.File, 110 | Category: "files", 111 | }, 112 | &cli.StringFlag{ 113 | Name: "directory", 114 | Aliases: []string{"d"}, 115 | Usage: "Destination to update modules", 116 | Value: ".", 117 | Destination: &myFlags.Directory, 118 | Category: "files", 119 | }, 120 | &cli.BoolFlag{ 121 | Name: "update", 122 | Usage: "update to latest module available", 123 | Destination: &myFlags.Update, 124 | Value: false, 125 | }, 126 | &cli.BoolFlag{ 127 | Name: "dry-run", 128 | Usage: "show but don't write changes", 129 | Destination: &myFlags.DryRun, 130 | Value: false, 131 | }, 132 | &cli.StringFlag{ 133 | Name: "token", 134 | Aliases: []string{"t"}, 135 | Usage: "Github PAT token", 136 | Destination: &myFlags.GitHubToken, 137 | Category: "authentication", 138 | EnvVars: []string{"GITHUB_TOKEN", "GITHUB_API"}, 139 | }, 140 | }, 141 | }, 142 | { 143 | Name: "sift", 144 | Aliases: []string{"p"}, 145 | Usage: "updates pre-commit version with hashes", 146 | UsageText: "ghat sift", 147 | Action: func(*cli.Context) error { 148 | return myFlags.Action("sift") 149 | }, 150 | Flags: []cli.Flag{ 151 | &cli.StringFlag{ 152 | Name: "directory", 153 | Aliases: []string{"d"}, 154 | Usage: "Destination to update modules", 155 | Destination: &myFlags.Directory, 156 | Category: "files", 157 | }, 158 | &cli.BoolFlag{ 159 | Name: "dry-run", 160 | Usage: "show but don't write changes", 161 | Destination: &myFlags.DryRun, 162 | Value: false, 163 | }, 164 | &cli.StringFlag{ 165 | Name: "token", 166 | Aliases: []string{"t"}, 167 | Usage: "Github PAT token", 168 | Destination: &myFlags.GitHubToken, 169 | Category: "authentication", 170 | EnvVars: []string{"GITHUB_TOKEN", "GITHUB_API"}, 171 | }, 172 | }, 173 | }, 174 | }, 175 | Name: "ghat", 176 | Usage: "Update GHA dependencies", 177 | Compiled: time.Time{}, 178 | Authors: []*cli.Author{{Name: "James Woolfenden", Email: "jim.wolf@duck.com"}}, 179 | Version: version.Version, 180 | } 181 | sort.Sort(cli.FlagsByName(app.Flags)) 182 | sort.Sort(cli.CommandsByName(app.Commands)) 183 | 184 | if err := app.Run(os.Args); err != nil { 185 | log.Fatal().Err(err).Msg("ghat failure") 186 | } 187 | } 188 | -------------------------------------------------------------------------------- /set-version.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | latestTag=$(git describe --tags) 3 | echo "Updating version file with new tag: $latestTag" 4 | echo "package version" > src/version/version.go 5 | echo "" >> src/version/version.go 6 | echo "const Version = \"$latestTag\"" >> src/version/version.go 7 | -------------------------------------------------------------------------------- /src/core/action.go: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | ) 7 | 8 | const ( 9 | ActionSwipe = "swipe" 10 | ActionSwot = "swot" 11 | ActionSift = "sift" 12 | ) 13 | 14 | func (myFlags *Flags) Action(action string) error { 15 | var err error 16 | 17 | if action == "" { 18 | return &actionIsEmptyError{} 19 | } 20 | 21 | if myFlags.File != "" { 22 | if _, err := os.Stat(myFlags.File); err != nil { 23 | pwd, err := os.Getwd() 24 | if err != nil { 25 | return &workingDirectoryError{pwd} 26 | } 27 | myFlags.File = filepath.Join(pwd, myFlags.File) 28 | } 29 | 30 | myFlags.Entries = append(myFlags.Entries, myFlags.File) 31 | } else { 32 | myFlags.Entries, err = GetFiles(myFlags.Directory) 33 | 34 | if err != nil { 35 | return &directoryReadError{myFlags.Directory} 36 | } 37 | } 38 | 39 | err = executeAction(action, myFlags) 40 | if err != nil { 41 | return &executeActionError{action} 42 | } 43 | 44 | return nil 45 | } 46 | 47 | func executeAction(action string, myFlags *Flags) error { 48 | if myFlags == nil { 49 | return &actionIsEmptyError{} 50 | } 51 | 52 | if myFlags.File == "" && myFlags.Directory == "" { 53 | return &dirAndFileEmptyError{} 54 | } 55 | 56 | switch action { 57 | case ActionSwipe: 58 | if myFlags.File != "" { 59 | return myFlags.UpdateModule(myFlags.File) 60 | } else { 61 | return myFlags.UpdateModules() 62 | } 63 | case ActionSwot: 64 | { 65 | if myFlags.File != "" { 66 | return myFlags.UpdateGHA(myFlags.File) 67 | } else { 68 | return myFlags.UpdateGHAS() 69 | } 70 | } 71 | case ActionSift: 72 | { 73 | return myFlags.UpdateHooks() 74 | } 75 | } 76 | 77 | return nil 78 | } 79 | -------------------------------------------------------------------------------- /src/core/action_test.go: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | import ( 4 | "os" 5 | "testing" 6 | ) 7 | 8 | func TestFlags_Action(t *testing.T) { 9 | t.Parallel() 10 | type fields struct { 11 | File string 12 | Directory string 13 | GitHubToken string 14 | Days uint 15 | DryRun bool 16 | Entries []string 17 | Update bool 18 | } 19 | 20 | type args struct { 21 | Action string 22 | } 23 | 24 | dir := fields{"", "testdata/files/", gitHubToken, 0, false, nil, true} 25 | bogus := fields{"", "testdata/bogus/", gitHubToken, 0, false, nil, true} 26 | empty := fields{"", "testdata/empty", gitHubToken, 0, false, nil, true} 27 | dirDry := fields{"", "testdata/files/", gitHubToken, 0, true, nil, true} 28 | fileGHA := fields{"testdata/files/ci.yml", "testdata/files/", gitHubToken, 0, true, nil, true} 29 | file := fields{"testdata/files/module.tf", "testdata/files/", gitHubToken, 0, true, nil, true} 30 | noFile := fields{"testdata/files/guff.tf", "testdata/files/", gitHubToken, 0, true, nil, true} 31 | 32 | _ = os.Remove("testdata/empty") 33 | 34 | tests := []struct { 35 | name string 36 | fields fields 37 | args args 38 | wantErr bool 39 | }{ 40 | {"Pass", dir, args{}, true}, 41 | {"Bogus", bogus, args{}, true}, 42 | {"Empty swot", empty, args{"swot"}, true}, 43 | {"Empty swipe", empty, args{"swipe"}, true}, 44 | {"dirDry", dirDry, args{}, true}, 45 | {"file swipe", file, args{"swipe"}, false}, 46 | {"file swot", fileGHA, args{"swot"}, false}, 47 | {"file swot empty", dirDry, args{"swot"}, false}, 48 | {"file swipe empty", dirDry, args{"swipe"}, false}, 49 | {"no file", noFile, args{"swipe"}, true}, 50 | {"sift", fields{Directory: "../../"}, args{"sift"}, false}, 51 | } 52 | 53 | for _, tt := range tests { 54 | tt := tt 55 | t.Run(tt.name, func(t *testing.T) { 56 | t.Parallel() 57 | myFlags := &Flags{ 58 | File: tt.fields.File, 59 | Directory: tt.fields.Directory, 60 | GitHubToken: tt.fields.GitHubToken, 61 | Days: tt.fields.Days, 62 | DryRun: tt.fields.DryRun, 63 | Entries: tt.fields.Entries, 64 | Update: tt.fields.Update, 65 | } 66 | if err := myFlags.Action(tt.args.Action); (err != nil) != tt.wantErr { 67 | t.Errorf("Action() error = %v, wantErr %v", err, tt.wantErr) 68 | } 69 | }) 70 | } 71 | } 72 | 73 | func TestExecuteAction(t *testing.T) { 74 | t.Parallel() 75 | type args struct { 76 | action string 77 | myFlags *Flags 78 | } 79 | 80 | tests := []struct { 81 | name string 82 | args args 83 | wantErr bool 84 | }{ 85 | { 86 | name: "Unknown action type", 87 | args: args{ 88 | action: "unknown", 89 | myFlags: &Flags{ 90 | File: "", 91 | Directory: "testdata/files/", 92 | GitHubToken: gitHubToken, 93 | }, 94 | }, 95 | wantErr: false, 96 | }, 97 | { 98 | name: "Swipe with nil flags", 99 | args: args{ 100 | action: ActionSwipe, 101 | myFlags: nil, 102 | }, 103 | wantErr: true, 104 | }, 105 | { 106 | name: "Swot with empty file and directory", 107 | args: args{ 108 | action: ActionSwot, 109 | myFlags: &Flags{ 110 | File: "", 111 | Directory: "", 112 | GitHubToken: "", 113 | }, 114 | }, 115 | wantErr: true, 116 | }, 117 | { 118 | name: "Sift with missing GitHub token", 119 | args: args{ 120 | action: ActionSift, 121 | myFlags: &Flags{ 122 | File: "", 123 | Directory: "testdata/files/", 124 | GitHubToken: "", 125 | }, 126 | }, 127 | wantErr: true, 128 | }, 129 | { 130 | name: "Swipe with invalid file path format", 131 | args: args{ 132 | action: ActionSwipe, 133 | myFlags: &Flags{ 134 | File: "///", 135 | Directory: "testdata/files/", 136 | GitHubToken: gitHubToken, 137 | }, 138 | }, 139 | wantErr: true, 140 | }, 141 | } 142 | 143 | for _, tt := range tests { 144 | tt := tt 145 | t.Run(tt.name, func(t *testing.T) { 146 | t.Parallel() 147 | if err := executeAction(tt.args.action, tt.args.myFlags); (err != nil) != tt.wantErr { 148 | t.Errorf("executeAction() error = %v, wantErr %v", err, tt.wantErr) 149 | } 150 | }) 151 | } 152 | } 153 | -------------------------------------------------------------------------------- /src/core/error.go: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | import "fmt" 4 | 5 | type actionIsEmptyError struct { 6 | } 7 | 8 | func (m *actionIsEmptyError) Error() string { 9 | return "action is empty" 10 | } 11 | 12 | type directoryReadError struct { 13 | directory string 14 | } 15 | 16 | func (m *directoryReadError) Error() string { 17 | return fmt.Sprintf("action failed to read %s", m.directory) 18 | } 19 | 20 | type workingDirectoryError struct { 21 | directory string 22 | } 23 | 24 | func (m *workingDirectoryError) Error() string { 25 | return fmt.Sprintf("failed to get working directory: %s", m.directory) 26 | } 27 | 28 | type executeActionError struct { 29 | action string 30 | } 31 | 32 | func (m *executeActionError) Error() string { 33 | return fmt.Sprintf("failed to execute action: %s", m.action) 34 | } 35 | 36 | type dirAndFileEmptyError struct { 37 | } 38 | 39 | func (m *dirAndFileEmptyError) Error() string { 40 | return "file and directory are empty" 41 | } 42 | 43 | type ghaUpdateError struct { 44 | gha string 45 | } 46 | 47 | func (m *ghaUpdateError) Error() string { 48 | return fmt.Sprintf("GHA update error %s", m.gha) 49 | } 50 | 51 | type ghaFileError struct { 52 | file string 53 | } 54 | 55 | func (m *ghaFileError) Error() string { 56 | return fmt.Sprintf("GHA file error %s", m.file) 57 | } 58 | 59 | type castToMapError struct { 60 | object string 61 | } 62 | 63 | func (m *castToMapError) Error() string { 64 | return fmt.Sprintf("failed to cast %s to map[string]interface{}", m.object) 65 | } 66 | 67 | type writeGHAError struct { 68 | gha string 69 | } 70 | 71 | func (m *writeGHAError) Error() string { 72 | return fmt.Sprintf("failed to write GHA %s", m.gha) 73 | } 74 | 75 | type readConfigError struct { 76 | config *string 77 | err error 78 | } 79 | 80 | func (m *readConfigError) Error() string { 81 | return fmt.Sprintf("failed to read %s: %v", *m.config, m.err) 82 | } 83 | 84 | type marshalJSONError struct { 85 | err error 86 | } 87 | 88 | func (m *marshalJSONError) Error() string { 89 | return fmt.Sprintf("failed to marshal JSON: %v", m.err) 90 | } 91 | 92 | type getHookError struct { 93 | err error 94 | } 95 | 96 | func (m *getHookError) Error() string { 97 | return fmt.Sprintf("failed to get hook: %v", m.err) 98 | } 99 | 100 | type castToStringError struct { 101 | object string 102 | } 103 | 104 | func (m *castToStringError) Error() string { 105 | return fmt.Sprintf("failed to cast %s to string", m.object) 106 | } 107 | 108 | type requestFailedError struct { 109 | err error 110 | } 111 | 112 | func (m *requestFailedError) Error() string { 113 | return fmt.Sprintf("request failed: %v", m.err) 114 | } 115 | 116 | type httpClientError struct { 117 | err error 118 | } 119 | 120 | func (m *httpClientError) Error() string { 121 | return fmt.Sprintf("http client error: %v", m.err) 122 | } 123 | 124 | type emptyURL struct { 125 | } 126 | 127 | func (m *emptyURL) Error() string { 128 | return "URL is empty" 129 | } 130 | 131 | type registryModuleError struct { 132 | module string 133 | err error 134 | } 135 | 136 | func (m *registryModuleError) Error() string { 137 | return fmt.Sprintf("failed to get module %s: %v", m.module, m.err) 138 | } 139 | 140 | type httpGetError struct { 141 | err error 142 | } 143 | 144 | func (m *httpGetError) Error() string { 145 | return fmt.Sprintf("http get error: %v", m.err) 146 | } 147 | 148 | type unmarshalJSONError struct { 149 | err error 150 | } 151 | 152 | func (m *unmarshalJSONError) Error() string { 153 | return fmt.Sprintf("failed to unmarshal: %v", m.err) 154 | } 155 | 156 | type moduleEmptyError struct { 157 | } 158 | 159 | func (m *moduleEmptyError) Error() string { 160 | return "module name cannot be empty" 161 | } 162 | 163 | type responseReadError struct { 164 | err error 165 | } 166 | 167 | func (m *responseReadError) Error() string { 168 | return fmt.Sprintf("failed to read response: %v", m.err) 169 | } 170 | 171 | type responseNilError struct { 172 | } 173 | 174 | func (m *responseNilError) Error() string { 175 | return "api response is nil" 176 | } 177 | 178 | type githubTokenIsEmptyError struct{} 179 | 180 | func (e githubTokenIsEmptyError) Error() string { 181 | return "github token is empty" 182 | } 183 | 184 | type timeParsingError struct { 185 | err error 186 | } 187 | 188 | func (e timeParsingError) Error() string { 189 | return fmt.Sprintf("failed to parse time %v", e.err) 190 | } 191 | 192 | type daysParameterError struct{} 193 | 194 | func (e daysParameterError) Error() string { 195 | return "days parameter must be positive" 196 | } 197 | -------------------------------------------------------------------------------- /src/core/error_test.go: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "testing" 7 | ) 8 | 9 | func TestActionIsEmptyError(t *testing.T) { 10 | t.Parallel() 11 | err := &actionIsEmptyError{} 12 | expected := "action is empty" 13 | if err.Error() != expected { 14 | t.Errorf("Expected error message '%s', got '%s'", expected, err.Error()) 15 | } 16 | } 17 | 18 | func TestDirectoryReadError(t *testing.T) { 19 | t.Parallel() 20 | testCases := []struct { 21 | name string 22 | directory string 23 | expected string 24 | }{ 25 | {"Empty directory", "", "action failed to read "}, 26 | {"Valid directory", "/test/dir", "action failed to read /test/dir"}, 27 | {"Relative path", "./relative", "action failed to read ./relative"}, 28 | } 29 | 30 | for _, tc := range testCases { 31 | tc := tc 32 | t.Run(tc.name, func(t *testing.T) { 33 | t.Parallel() 34 | err := &directoryReadError{directory: tc.directory} 35 | if err.Error() != tc.expected { 36 | t.Errorf("Expected error message '%s', got '%s'", tc.expected, err.Error()) 37 | } 38 | }) 39 | } 40 | } 41 | 42 | func TestWorkingDirectoryError(t *testing.T) { 43 | t.Parallel() 44 | testCases := []struct { 45 | name string 46 | directory string 47 | expected string 48 | }{ 49 | {"Empty directory", "", "failed to get working directory: "}, 50 | {"Valid directory", "/home/user", "failed to get working directory: /home/user"}, 51 | {"Windows path", "C:\\Users\\test", "failed to get working directory: C:\\Users\\test"}, 52 | } 53 | 54 | for _, tc := range testCases { 55 | tc := tc 56 | t.Run(tc.name, func(t *testing.T) { 57 | t.Parallel() 58 | err := &workingDirectoryError{directory: tc.directory} 59 | if err.Error() != tc.expected { 60 | t.Errorf("Expected error message '%s', got '%s'", tc.expected, err.Error()) 61 | } 62 | }) 63 | } 64 | } 65 | 66 | func TestExecuteActionError(t *testing.T) { 67 | t.Parallel() 68 | testCases := []struct { 69 | name string 70 | action string 71 | expected string 72 | }{ 73 | {"Empty action", "", "failed to execute action: "}, 74 | {"Simple action", "build", "failed to execute action: build"}, 75 | {"Complex action", "deploy --force --env=prod", "failed to execute action: deploy --force --env=prod"}, 76 | } 77 | 78 | for _, tc := range testCases { 79 | tc := tc 80 | t.Run(tc.name, func(t *testing.T) { 81 | t.Parallel() 82 | err := &executeActionError{action: tc.action} 83 | if err.Error() != tc.expected { 84 | t.Errorf("Expected error message '%s', got '%s'", tc.expected, err.Error()) 85 | } 86 | }) 87 | } 88 | } 89 | 90 | func TestDirAndFileEmptyError(t *testing.T) { 91 | t.Parallel() 92 | err := &dirAndFileEmptyError{} 93 | expected := "file and directory are empty" 94 | if err.Error() != expected { 95 | t.Errorf("Expected error message '%s', got '%s'", expected, err.Error()) 96 | } 97 | } 98 | 99 | func TestGHAUpdateError(t *testing.T) { 100 | t.Parallel() 101 | testCases := []struct { 102 | name string 103 | gha string 104 | expected string 105 | }{ 106 | {"Empty GHA", "", "GHA update error "}, 107 | {"Valid GHA", "workflow.yml", "GHA update error workflow.yml"}, 108 | {"Path GHA", ".github/workflows/test.yml", "GHA update error .github/workflows/test.yml"}, 109 | } 110 | 111 | for _, tc := range testCases { 112 | tc := tc 113 | t.Run(tc.name, func(t *testing.T) { 114 | t.Parallel() 115 | err := &ghaUpdateError{gha: tc.gha} 116 | if err.Error() != tc.expected { 117 | t.Errorf("Expected error message '%s', got '%s'", tc.expected, err.Error()) 118 | } 119 | }) 120 | } 121 | } 122 | 123 | func TestGHAFileError(t *testing.T) { 124 | t.Parallel() 125 | testCases := []struct { 126 | name string 127 | file string 128 | expected string 129 | }{ 130 | {"Empty file", "", "GHA file error "}, 131 | {"Simple file", "main.yml", "GHA file error main.yml"}, 132 | {"Nested file", "workflows/deploy.yml", "GHA file error workflows/deploy.yml"}, 133 | } 134 | 135 | for _, tc := range testCases { 136 | tc := tc 137 | t.Run(tc.name, func(t *testing.T) { 138 | t.Parallel() 139 | err := &ghaFileError{file: tc.file} 140 | if err.Error() != tc.expected { 141 | t.Errorf("Expected error message '%s', got '%s'", tc.expected, err.Error()) 142 | } 143 | }) 144 | } 145 | } 146 | 147 | func TestCastToMapError(t *testing.T) { 148 | t.Parallel() 149 | testCases := []struct { 150 | name string 151 | object string 152 | expected string 153 | }{ 154 | {"Empty object", "", "failed to cast to map[string]interface{}"}, 155 | {"Simple object", "config", "failed to cast config to map[string]interface{}"}, 156 | {"Complex object", "workflow.settings", "failed to cast workflow.settings to map[string]interface{}"}, 157 | } 158 | 159 | for _, tc := range testCases { 160 | tc := tc 161 | t.Run(tc.name, func(t *testing.T) { 162 | t.Parallel() 163 | err := &castToMapError{object: tc.object} 164 | if err.Error() != tc.expected { 165 | t.Errorf("Expected error message '%s', got '%s'", tc.expected, err.Error()) 166 | } 167 | }) 168 | } 169 | } 170 | 171 | func TestWriteGHAError(t *testing.T) { 172 | t.Parallel() 173 | testCases := []struct { 174 | name string 175 | gha string 176 | expected string 177 | }{ 178 | {"Empty GHA", "", "failed to write GHA "}, 179 | {"Simple GHA", "ci.yml", "failed to write GHA ci.yml"}, 180 | {"Full path GHA", ".github/workflows/release.yml", "failed to write GHA .github/workflows/release.yml"}, 181 | } 182 | 183 | for _, tc := range testCases { 184 | tc := tc 185 | t.Run(tc.name, func(t *testing.T) { 186 | t.Parallel() 187 | err := &writeGHAError{gha: tc.gha} 188 | if err.Error() != tc.expected { 189 | t.Errorf("Expected error message '%s', got '%s'", tc.expected, err.Error()) 190 | } 191 | }) 192 | } 193 | } 194 | 195 | func TestReadConfigError(t *testing.T) { 196 | t.Parallel() 197 | config := "config.yaml" 198 | testErr := fmt.Errorf("test error") 199 | err := &readConfigError{config: &config, err: testErr} 200 | expected := "failed to read config.yaml: test error" 201 | if err.Error() != expected { 202 | t.Errorf("Expected error message '%s', got '%s'", expected, err.Error()) 203 | } 204 | } 205 | 206 | func TestMarshalJSONError(t *testing.T) { 207 | t.Parallel() 208 | testErr := fmt.Errorf("marshal error") 209 | err := &marshalJSONError{err: testErr} 210 | expected := "failed to marshal JSON: marshal error" 211 | if err.Error() != expected { 212 | t.Errorf("Expected error message '%s', got '%s'", expected, err.Error()) 213 | } 214 | } 215 | 216 | func TestGetHookError(t *testing.T) { 217 | t.Parallel() 218 | testErr := fmt.Errorf("hook error") 219 | err := &getHookError{err: testErr} 220 | expected := "failed to get hook: hook error" 221 | if err.Error() != expected { 222 | t.Errorf("Expected error message '%s', got '%s'", expected, err.Error()) 223 | } 224 | } 225 | 226 | func TestCastToStringError(t *testing.T) { 227 | t.Parallel() 228 | testCases := []struct { 229 | name string 230 | object string 231 | expected string 232 | }{ 233 | {"Empty object", "", "failed to cast to string"}, 234 | {"Valid object", "testObject", "failed to cast testObject to string"}, 235 | } 236 | 237 | for _, tc := range testCases { 238 | tc := tc 239 | t.Run(tc.name, func(t *testing.T) { 240 | t.Parallel() 241 | err := &castToStringError{object: tc.object} 242 | if err.Error() != tc.expected { 243 | t.Errorf("Expected error message '%s', got '%s'", tc.expected, err.Error()) 244 | } 245 | }) 246 | } 247 | } 248 | 249 | func TestRequestFailedError(t *testing.T) { 250 | t.Parallel() 251 | testErr := fmt.Errorf("request error") 252 | err := &requestFailedError{err: testErr} 253 | expected := "request failed: request error" 254 | if err.Error() != expected { 255 | t.Errorf("Expected error message '%s', got '%s'", expected, err.Error()) 256 | } 257 | } 258 | 259 | func TestHTTPClientError(t *testing.T) { 260 | t.Parallel() 261 | testErr := fmt.Errorf("client error") 262 | err := &httpClientError{err: testErr} 263 | expected := "http client error: client error" 264 | if err.Error() != expected { 265 | t.Errorf("Expected error message '%s', got '%s'", expected, err.Error()) 266 | } 267 | } 268 | 269 | func TestEmptyURL(t *testing.T) { 270 | t.Parallel() 271 | err := &emptyURL{} 272 | expected := "URL is empty" 273 | if err.Error() != expected { 274 | t.Errorf("Expected error message '%s', got '%s'", expected, err.Error()) 275 | } 276 | } 277 | 278 | func TestRegistryModuleError(t *testing.T) { 279 | t.Parallel() 280 | testErr := fmt.Errorf("module error") 281 | err := ®istryModuleError{module: "test-module", err: testErr} 282 | expected := "failed to get module test-module: module error" 283 | if err.Error() != expected { 284 | t.Errorf("Expected error message '%s', got '%s'", expected, err.Error()) 285 | } 286 | } 287 | 288 | func TestHTTPGetError(t *testing.T) { 289 | t.Parallel() 290 | testErr := fmt.Errorf("get error") 291 | err := &httpGetError{err: testErr} 292 | expected := "http get error: get error" 293 | if err.Error() != expected { 294 | t.Errorf("Expected error message '%s', got '%s'", expected, err.Error()) 295 | } 296 | } 297 | 298 | func TestUnmarshalJSONError(t *testing.T) { 299 | t.Parallel() 300 | testErr := fmt.Errorf("unmarshal error") 301 | err := &unmarshalJSONError{err: testErr} 302 | expected := "failed to unmarshal: unmarshal error" 303 | if err.Error() != expected { 304 | t.Errorf("Expected error message '%s', got '%s'", expected, err.Error()) 305 | } 306 | } 307 | 308 | func TestModuleEmptyError(t *testing.T) { 309 | t.Parallel() 310 | err := &moduleEmptyError{} 311 | expected := "module name cannot be empty" 312 | if err.Error() != expected { 313 | t.Errorf("Expected error message '%s', got '%s'", expected, err.Error()) 314 | } 315 | } 316 | 317 | func TestResponseReadError(t *testing.T) { 318 | t.Parallel() 319 | testErr := fmt.Errorf("read error") 320 | err := &responseReadError{err: testErr} 321 | expected := "failed to read response: read error" 322 | if err.Error() != expected { 323 | t.Errorf("Expected error message '%s', got '%s'", expected, err.Error()) 324 | } 325 | } 326 | 327 | func TestResponseNilError(t *testing.T) { 328 | t.Parallel() 329 | err := &responseNilError{} 330 | expected := "api response is nil" 331 | if err.Error() != expected { 332 | t.Errorf("Expected error message '%s', got '%s'", expected, err.Error()) 333 | } 334 | } 335 | 336 | func TestTimeParsingError(t *testing.T) { 337 | testErr := errors.New("test error") 338 | err := timeParsingError{err: testErr} 339 | 340 | expected := "failed to parse time test error" 341 | if got := err.Error(); got != expected { 342 | t.Errorf("timeParsingError.Error() = %v, want %v", got, expected) 343 | } 344 | } 345 | 346 | func TestDaysParameterError(t *testing.T) { 347 | err := daysParameterError{} 348 | 349 | expected := "days parameter must be positive" 350 | if got := err.Error(); got != expected { 351 | t.Errorf("daysParameterError.Error() = %v, want %v", got, expected) 352 | } 353 | } 354 | 355 | func TestErrorInterfaces(t *testing.T) { 356 | // Verify types implement error interface 357 | var _ error = timeParsingError{} 358 | var _ error = daysParameterError{} 359 | } 360 | -------------------------------------------------------------------------------- /src/core/filter.go: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | import ( 4 | "fmt" 5 | "time" 6 | ) 7 | 8 | const ( 9 | dayInNanos int64 = 24 * 60 * 60 * 1000 * 1000 * 1000 10 | apiBaseURL = "https://api.github.com/repos/" 11 | ) 12 | 13 | func GetReleases(action string, gitHubToken string, days *uint) (map[string]interface{}, error) { 14 | if days == nil { 15 | return nil, &daysParameterError{} 16 | } 17 | 18 | if gitHubToken == "" { 19 | return nil, &githubTokenIsEmptyError{} 20 | } 21 | 22 | if action == "" { 23 | return nil, &actionIsEmptyError{} 24 | } 25 | 26 | now := time.Now() 27 | interval := time.Duration(int64(*days) * dayInNanos) 28 | limit := now.Add(-interval) 29 | 30 | url := apiBaseURL + action + "/releases" 31 | temp, err := GetGithubBody(gitHubToken, url) 32 | 33 | if err != nil { 34 | return nil, fmt.Errorf("failed to request list of releases %w", err) 35 | } 36 | 37 | bodies, ok := temp.([]interface{}) 38 | 39 | if !ok { 40 | return nil, fmt.Errorf("api query did not return list: %s", bodies) 41 | } 42 | 43 | for _, body := range bodies { 44 | release, ok := body.(map[string]interface{}) 45 | if !ok { 46 | return nil, fmt.Errorf("invalid release format in response") 47 | } 48 | 49 | temp, ok := release["published_at"].(string) 50 | 51 | if !ok { 52 | return nil, &castToStringError{"published_at"} 53 | } 54 | 55 | released, err := time.Parse(time.RFC3339, temp) 56 | 57 | if err != nil { 58 | return nil, &timeParsingError{err: err} 59 | } 60 | 61 | if released.Before(limit) { 62 | return release, nil 63 | } 64 | } 65 | 66 | return nil, nil 67 | } 68 | -------------------------------------------------------------------------------- /src/core/filter_test.go: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestGetReleases(t *testing.T) { 8 | t.Parallel() 9 | 10 | type args struct { 11 | action string 12 | gitHubToken string 13 | delay *uint 14 | } 15 | 16 | var delay uint = 14 17 | var zero uint = 0 18 | var empty map[string]interface{} 19 | want := map[string]interface{}{ 20 | "tarball_url": "https: //api.github.com/repos/JamesWoolfenden/test-data-action/tarball/v0.0.1", 21 | "zipball_url": "https: //api.github.com/repos/JamesWoolfenden/test-data-action/zipball/v0.0.1", 22 | "assets_url": "https: //api.github.com/repos/JamesWoolfenden/test-data-action/releases/109328421/assets", 23 | "id": 109328421, 24 | "draft": false, 25 | "created_at": "2023-06-21T06:59:22Z", 26 | "published_at": "2023-06-21T06:59:51Z", 27 | "assets": []interface{}{}, 28 | "html_url": "https: //github.com/JamesWoolfenden/test-data-action/releases/tag/v0.0.1", 29 | "author": map[string]interface{}{ 30 | "avatar_url": "https://avatars.githubusercontent.com/u/1456880?v=4", 31 | "url": "https://api.github.com/users/JamesWoolfenden", 32 | "type": "User", 33 | "followers_url": "https://api.github.com/users/JamesWoolfenden/followers", 34 | "organizations_url": "https://api.github.com/users/JamesWoolfenden/orgs", 35 | "starred_url": "https://api.github.com/users/JamesWoolfenden/starred{/owner}{/repo}", 36 | "events_url": "https://api.github.com/users/JamesWoolfenden/events{/privacy}", 37 | "login": "JamesWoolfenden", 38 | "id": 1456880, 39 | "node_id": "MDQ6VXNlcjE0NTY4ODA=", 40 | "gravatar_id": "", 41 | "html_url": "https://github.com/JamesWoolfenden", 42 | "following_url": "https://api.github.com/users/JamesWoolfenden/following{/other_user}", 43 | "gists_url": "https://api.github.com/users/JamesWoolfenden/gists{/gist_id}", 44 | "subscriptions_url": "https://api.github.com/users/JamesWoolfenden/subscriptions", 45 | "repos_url": "https://api.github.com/users/JamesWoolfenden/repos", 46 | "received_events_url": "https://api.github.com/users/JamesWoolfenden/received_events", 47 | "site_admin": false, 48 | }, 49 | "node_id": "RE_kwDOJyIXLs4GhDgl", 50 | "tag_name": "v0.0.1", 51 | "name": "Test", 52 | "prerelease": false, 53 | "body": "", 54 | "url": "https://api.github.com/repos/JamesWoolfenden/test-data-action/releases/109328421", 55 | "upload_url": "https: //uploads.github.com/repos/JamesWoolfenden/test-data-action/releases/109328421/assets{?name,label}", 56 | "target_commitish": "main", 57 | } 58 | 59 | result := map[string]interface{}{ 60 | "tarball_url": "https: //api.github.com/repos/JamesWoolfenden/test-data-action/tarball/v0.0.1", 61 | "zipball_url": "https: //api.github.com/repos/JamesWoolfenden/test-data-action/zipball/v0.0.1", 62 | "assets_url": "https: //api.github.com/repos/JamesWoolfenden/test-data-action/releases/109328421/assets", 63 | "id": 109328421, 64 | "draft": false, 65 | "created_at": "2023-06-21T06:59:22Z", 66 | "published_at": "2023-06-21T06:59:51Z", 67 | "assets": []interface{}{}, 68 | "html_url": "https: //github.com/JamesWoolfenden/test-data-action/releases/tag/v0.0.1", 69 | "author": map[string]interface{}{ 70 | "avatar_url": "https://avatars.githubusercontent.com/u/1456880?v=4", 71 | "url": "https://api.github.com/users/JamesWoolfenden", 72 | "type": "User", 73 | "followers_url": "https://api.github.com/users/JamesWoolfenden/followers", 74 | "organizations_url": "https://api.github.com/users/JamesWoolfenden/orgs", 75 | "starred_url": "https://api.github.com/users/JamesWoolfenden/starred{/owner}{/repo}", 76 | "events_url": "https://api.github.com/users/JamesWoolfenden/events{/privacy}", 77 | "login": "JamesWoolfenden", 78 | "id": 1456880, 79 | "node_id": "MDQ6VXNlcjE0NTY4ODA=", 80 | "gravatar_id": "", 81 | "html_url": "https://github.com/JamesWoolfenden", 82 | "following_url": "https://api.github.com/users/JamesWoolfenden/following{/other_user}", 83 | "gists_url": "https://api.github.com/users/JamesWoolfenden/gists{/gist_id}", 84 | "subscriptions_url": "https://api.github.com/users/JamesWoolfenden/subscriptions", 85 | "repos_url": "https://api.github.com/users/JamesWoolfenden/repos", 86 | "received_events_url": "https://api.github.com/users/JamesWoolfenden/received_events", 87 | "site_admin": false, 88 | }, 89 | "node_id": "RE_kwDOJyIXLs4GhDgl", 90 | "tag_name": "v0.0.1", 91 | "name": "Test", 92 | "prerelease": false, 93 | "body": "", 94 | "url": "https://api.github.com/repos/JamesWoolfenden/test-data-action/releases/109328421", 95 | "upload_url": "https: //uploads.github.com/repos/JamesWoolfenden/test-data-action/releases/109328421/assets{?name,label}", 96 | "target_commitish": "main", 97 | } 98 | 99 | tests := []struct { 100 | name string 101 | args args 102 | want map[string]interface{} 103 | wantErr bool 104 | }{ 105 | {"Pass", args{"jameswoolfenden/empty", gitHubToken, &delay}, empty, false}, 106 | {"Has release", args{"jameswoolfenden/test-data-action", gitHubToken, &delay}, want, false}, 107 | {"Has released", args{"jameswoolfenden/test-data-action", gitHubToken, &zero}, result, false}, 108 | {"Fake", args{"jameswoolfenden/god", gitHubToken, &zero}, nil, true}, 109 | {"no token", args{"actions/checkout", "", &zero}, nil, true}, 110 | } 111 | 112 | for _, tt := range tests { 113 | tt := tt 114 | t.Run(tt.name, func(t *testing.T) { 115 | t.Parallel() 116 | got, err := GetReleases(tt.args.action, tt.args.gitHubToken, tt.args.delay) 117 | if (err != nil) != tt.wantErr { 118 | t.Errorf("GetReleases() error = %v, wantErr %v", err, tt.wantErr) 119 | return 120 | } 121 | if !(got["tag_name"] == tt.want["tag_name"]) { 122 | t.Errorf("GetReleases() got = %v, want %v", got, tt.want) 123 | } 124 | }) 125 | } 126 | } 127 | 128 | func TestGetReleasesEdgeCases(t *testing.T) { 129 | t.Parallel() 130 | 131 | var days uint = 14 132 | var zero uint = 0 133 | 134 | tests := []struct { 135 | name string 136 | action string 137 | gitHubToken string 138 | days *uint 139 | wantErr bool 140 | errMsg string 141 | }{ 142 | { 143 | name: "Empty GitHub token", 144 | action: "JamesWoolfenden/test-data-action", 145 | gitHubToken: "", 146 | days: &days, 147 | wantErr: true, 148 | errMsg: "github token is empty", 149 | }, 150 | { 151 | name: "Empty action name", 152 | action: "", 153 | gitHubToken: "dummy-token", 154 | days: &days, 155 | wantErr: true, 156 | errMsg: "action is empty", 157 | }, 158 | { 159 | name: "Zero days filter", 160 | action: "JamesWoolfenden/test-data-action", 161 | gitHubToken: "dummy-token", 162 | days: &zero, 163 | wantErr: true, 164 | errMsg: "failed to request list of releases api failed with 401", 165 | }, 166 | { 167 | name: "Valid days filter", 168 | action: "JamesWoolfenden/test-data-action", 169 | gitHubToken: "dummy-token", 170 | days: &days, 171 | wantErr: true, 172 | errMsg: "failed to request list of releases api failed with 401", 173 | }, 174 | } 175 | 176 | for _, tt := range tests { 177 | tt := tt 178 | t.Run(tt.name, func(t *testing.T) { 179 | t.Parallel() 180 | got, err := GetReleases(tt.action, tt.gitHubToken, tt.days) 181 | if (err != nil) != tt.wantErr { 182 | t.Errorf("GetReleases() error = %v, wantErr %v", err, tt.wantErr) 183 | return 184 | } 185 | if err != nil && err.Error() != tt.errMsg { 186 | t.Errorf("GetReleases() error message = %v, want %v", err.Error(), tt.errMsg) 187 | } 188 | if !tt.wantErr && got == nil { 189 | t.Error("GetReleases() returned nil result when error not expected") 190 | } 191 | }) 192 | } 193 | } 194 | -------------------------------------------------------------------------------- /src/core/gha.go: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io" 7 | "net/http" 8 | "os" 9 | "path/filepath" 10 | "regexp" 11 | "strings" 12 | "time" 13 | 14 | "github.com/rs/zerolog/log" 15 | "github.com/sergi/go-diff/diffmatchpatch" 16 | ) 17 | 18 | const ( 19 | githubWorkflowPath = ".github/workflows" 20 | terraformDir = ".terraform" 21 | yamlExtension = ".yml" 22 | yamlAltExtension = ".yaml" 23 | ) 24 | 25 | type readFilesError struct { 26 | err error 27 | } 28 | 29 | func (m *readFilesError) Error() string { 30 | return fmt.Sprintf("failed to read files: %s", m.err) 31 | } 32 | 33 | type absolutePathError struct { 34 | directory string 35 | err error 36 | } 37 | 38 | func (m *absolutePathError) Error() string { 39 | return fmt.Sprintf("failed to get absolute path: %v %s ", m.err, m.directory) 40 | } 41 | 42 | func GetFiles(dir string) ([]string, error) { 43 | Entries, err := os.ReadDir(dir) 44 | if err != nil { 45 | return nil, &readFilesError{err} 46 | } 47 | 48 | var ParsedEntries []string 49 | 50 | for _, entry := range Entries { 51 | AbsDir, err := filepath.Abs(dir) 52 | if err != nil { 53 | return nil, &absolutePathError{dir, err} 54 | } 55 | gitDir := filepath.Join(AbsDir, ".git") 56 | 57 | if entry.IsDir() { 58 | 59 | newDir := filepath.Join(AbsDir, entry.Name()) 60 | 61 | if !(strings.Contains(newDir, terraformDir)) && newDir != gitDir { 62 | newEntries, err := GetFiles(newDir) 63 | 64 | if err != nil { 65 | return nil, err 66 | } 67 | 68 | ParsedEntries = append(ParsedEntries, newEntries...) 69 | } 70 | } else { 71 | myFile := filepath.Join(dir, entry.Name()) 72 | if !(strings.Contains(myFile, terraformDir)) { 73 | ParsedEntries = append(ParsedEntries, myFile) 74 | } 75 | } 76 | } 77 | 78 | return ParsedEntries, nil 79 | } 80 | 81 | func (myFlags *Flags) UpdateGHAS() error { 82 | var err error 83 | myFlags.Entries = myFlags.GetGHA() 84 | 85 | for _, gha := range myFlags.Entries { 86 | err = myFlags.UpdateGHA(gha) 87 | 88 | if err != nil { 89 | return &ghaUpdateError{gha} 90 | } 91 | } 92 | 93 | return nil 94 | } 95 | 96 | // GetGHA gets all the actions in a directory 97 | func (myFlags *Flags) GetGHA() []string { 98 | var ghat []string 99 | 100 | for _, match := range myFlags.Entries { 101 | match, _ = filepath.Abs(match) 102 | entry, _ := os.Stat(match) 103 | if strings.Contains(match, githubWorkflowPath) && !entry.IsDir() { 104 | if strings.Contains(match, yamlExtension) || (strings.Contains(match, yamlAltExtension)) { 105 | ghat = append(ghat, match) 106 | } 107 | } 108 | } 109 | 110 | return ghat 111 | } 112 | 113 | // UpdateGHA updates am action with latest dependencies 114 | func (myFlags *Flags) UpdateGHA(file string) error { 115 | buffer, err := os.ReadFile(file) 116 | if err != nil { 117 | return &ghaFileError{file} 118 | } 119 | 120 | replacement := string(buffer) 121 | 122 | var newUrl string 123 | 124 | r := regexp.MustCompile(`uses:(.*)`) 125 | matches := r.FindAllStringSubmatch(string(buffer), -1) 126 | for _, match := range matches { 127 | 128 | //is path 129 | if strings.Contains(match[1], ".github") { 130 | continue 131 | } 132 | 133 | action := strings.Split(match[1], "@") 134 | 135 | action[0] = strings.TrimSpace(action[0]) 136 | body, err := getPayload(action[0], myFlags.GitHubToken, &myFlags.Days) 137 | 138 | if err != nil { 139 | splitter := strings.SplitN(action[0], "/", 3) 140 | newUrl = splitter[0] + "/" + splitter[1] 141 | body, err = getPayload(newUrl, myFlags.GitHubToken, &myFlags.Days) 142 | if err != nil { 143 | if myFlags.ContinueOnError { 144 | log.Info().Err(err) 145 | continue 146 | } 147 | return fmt.Errorf("failed to retrieve data for action %s with %s", action[0], err) 148 | } 149 | } 150 | 151 | msg, ok := body.(map[string]interface{}) 152 | 153 | if !ok { 154 | return &castToMapError{"body"} 155 | } 156 | 157 | if msg["tag_name"] != nil { 158 | tag := msg["tag_name"].(string) 159 | 160 | url := action[0] 161 | 162 | if newUrl != "" { 163 | url = newUrl 164 | } 165 | 166 | payload, err := getHash(url, tag, myFlags.GitHubToken) 167 | if err != nil { 168 | log.Warn().Msgf("failed to retrieve commit hash %s for %s", err, action[0]) 169 | continue 170 | } 171 | 172 | body, ok := payload.(map[string]interface{}) 173 | if !ok { 174 | log.Warn().Msgf("Payload is not expected map %s", body) 175 | continue 176 | } 177 | 178 | object, ok := body["object"].(map[string]interface{}) 179 | if !ok { 180 | log.Warn().Msgf("failed to assert map of string %s", err) 181 | continue 182 | } 183 | 184 | sha, ok := object["sha"].(string) 185 | if !ok { 186 | log.Warn().Msgf("failed to assert string %s", err) 187 | continue 188 | } 189 | 190 | oldAction := action[0] + "@" + action[1] 191 | newAction := action[0] + "@" + sha + " # " + tag //GET /repos/{owner}/{repo}/git/ref/tags/{tag_name} 192 | 193 | replacement = strings.ReplaceAll(replacement, oldAction, newAction) 194 | } else { 195 | log.Warn().Msgf("tag field empty skipping %s", action[0]) 196 | } 197 | } 198 | 199 | dmp := diffmatchpatch.New() 200 | diffs := dmp.DiffMain(string(buffer), replacement, false) 201 | 202 | fmt.Println(dmp.DiffPrettyText(diffs)) 203 | 204 | if !myFlags.DryRun { 205 | newBuffer := []byte(replacement) 206 | 207 | err = os.WriteFile(file, newBuffer, 0644) 208 | 209 | if err != nil { 210 | return &writeGHAError{file} 211 | } 212 | } 213 | 214 | return nil 215 | } 216 | 217 | func getPayload(action string, gitHubToken string, days *uint) (interface{}, error) { 218 | 219 | if days == nil { 220 | return nil, &daysParameterError{} 221 | } 222 | 223 | if *days == 0 { 224 | return GetLatestRelease(action, gitHubToken) 225 | } 226 | 227 | return GetReleases(action, gitHubToken, days) 228 | } 229 | 230 | func GetLatestRelease(action string, gitHubToken string) (interface{}, error) { 231 | url := "https://api.github.com/repos/" + action + "/releases/latest" 232 | return GetGithubBody(gitHubToken, url) 233 | } 234 | 235 | func GetLatestTag(action string, gitHubToken string) (interface{}, error) { 236 | url := "https://api.github.com/repos/" + action + "/tags" 237 | tags, err := GetGithubBody(gitHubToken, url) 238 | tagged, ok := tags.([]interface{}) 239 | 240 | if !ok { 241 | return nil, fmt.Errorf("failed to assert slice %s", tags) 242 | } 243 | 244 | return tagged[0].(map[string]interface{}), err 245 | } 246 | 247 | func getHash(action string, tag string, gitHubToken string) (interface{}, error) { 248 | url := "https://api.github.com/repos/" + action + "/git/ref/tags/" + tag 249 | return GetGithubBody(gitHubToken, url) 250 | } 251 | 252 | // GetGithubBody requests a URL using gitHub PAT for auth 253 | func GetGithubBody(gitHubToken string, url string) (interface{}, error) { 254 | var body []byte 255 | 256 | if gitHubToken != "" { 257 | req, err := http.NewRequest("GET", url, nil) 258 | if err != nil { 259 | return nil, fmt.Errorf("request failed %w", err) 260 | } 261 | 262 | req.Header.Add("Authorization", "Bearer "+gitHubToken) 263 | client := &http.Client{ 264 | Timeout: time.Second * 30} 265 | 266 | resp, err := client.Do(req) 267 | 268 | if resp == nil { 269 | return nil, fmt.Errorf("api failed to respond") 270 | } 271 | 272 | if resp.StatusCode != 200 { 273 | return nil, fmt.Errorf("api failed with %d", resp.StatusCode) 274 | } 275 | 276 | defer func(Body io.ReadCloser) { 277 | _ = Body.Close() 278 | }(resp.Body) 279 | 280 | if err != nil { 281 | return nil, fmt.Errorf("client failed %w", err) 282 | } 283 | 284 | body, err = io.ReadAll(resp.Body) 285 | 286 | if err != nil { 287 | return nil, fmt.Errorf("failed to read body %w", err) 288 | } 289 | 290 | } else { 291 | log.Warn().Msgf("failing back to anonymous auth") 292 | resp, err := http.Get(url) 293 | if err != nil { 294 | return nil, fmt.Errorf("failed to get url %w", err) 295 | } 296 | 297 | body, err = io.ReadAll(resp.Body) 298 | if err != nil { 299 | return nil, fmt.Errorf("failed to read body %w", err) 300 | } 301 | } 302 | 303 | var msg interface{} 304 | 305 | err := json.Unmarshal(body, &msg) 306 | if err != nil { 307 | return nil, fmt.Errorf("failed to unmarshal %w", err) 308 | } 309 | 310 | return msg, nil 311 | } 312 | -------------------------------------------------------------------------------- /src/core/gha_test.go: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path/filepath" 7 | "reflect" 8 | "testing" 9 | 10 | "github.com/rs/zerolog/log" 11 | ) 12 | 13 | var gitHubToken = os.Getenv("GITHUB_TOKEN") 14 | 15 | func TestGetBody(t *testing.T) { 16 | t.Parallel() 17 | 18 | garbage := "guff-inhere" 19 | failUrl := "https://api.github.com/users/JamesWoolfenden2/orgs" 20 | url := "https://api.github.com/users/JamesWoolfenden/orgs" 21 | 22 | result := map[string]interface{}{ 23 | "login": "teamvulkan", 24 | "id": 46164047, 25 | "node_id": "MDEyOk9yZ2FuaXphdGlvbjQ2MTY0MDQ3", 26 | "url": "https://api.github.com/orgs/teamvulkan", 27 | "repos_url": "https://api.github.com/orgs/teamvulkan/repos", 28 | "events_url": "https://api.github.com/orgs/teamvulkan/events", 29 | "hooks_url": "https://api.github.com/orgs/teamvulkan/hooks", 30 | "issues_url": "https://api.github.com/orgs/teamvulkan/issues", 31 | "members_url": "https://api.github.com/orgs/teamvulkan/members{/member}", 32 | "public_members_url": "https://api.github.com/orgs/teamvulkan/public_members{/member}", 33 | "avatar_url": "https://avatars.githubusercontent.com/u/46164047?v=4", 34 | "description": "", 35 | } 36 | 37 | type args struct { 38 | gitHubToken string 39 | url string 40 | } 41 | 42 | tests := []struct { 43 | name string 44 | args args 45 | want interface{} 46 | wantErr bool 47 | }{ 48 | {"Pass", args{gitHubToken: gitHubToken, url: url}, result, false}, 49 | {"Pass no token", args{url: url}, result, false}, 50 | {"Fail 404", args{gitHubToken: gitHubToken, url: failUrl}, nil, true}, 51 | {"Garbage", args{gitHubToken: gitHubToken, url: garbage}, nil, true}, 52 | } 53 | for _, tt := range tests { 54 | tt := tt 55 | t.Run(tt.name, func(t *testing.T) { 56 | t.Parallel() 57 | got, err := GetGithubBody(tt.args.gitHubToken, tt.args.url) 58 | if (err != nil) != tt.wantErr { 59 | t.Errorf("GetGithubBody() error = %v, wantErr %v", err, tt.wantErr) 60 | return 61 | } 62 | if tt.want != nil { 63 | _, ok := got.([]interface{}) 64 | if !ok { 65 | log.Info().Msgf("assertion error %s", err) 66 | return 67 | } 68 | 69 | gotMap := got.([]interface{})[0].(map[string]interface{}) 70 | wanted := tt.want.(map[string]interface{}) 71 | 72 | if !reflect.DeepEqual(gotMap["node_id"], wanted["node_id"]) { 73 | t.Errorf("GetGithubBody() got = %v, want %v", got, tt.want) 74 | } 75 | return 76 | } 77 | if got != nil { 78 | t.Errorf("GetGithubBody() nillness got = %v, want %v", got, tt.want) 79 | } 80 | 81 | }) 82 | } 83 | } 84 | 85 | func Test_getHash(t *testing.T) { 86 | t.Parallel() 87 | 88 | type args struct { 89 | action string 90 | tag string 91 | gitHubToken string 92 | } 93 | 94 | want := map[string]interface{}{ 95 | "node_id": "MDM6UmVmMTk3ODE0NjI5OnJlZnMvdGFncy92NC4wLjA=", 96 | "object": map[string]interface{}{ 97 | "sha": "1e31de5234b9f8995739874a8ce0492dc87873e2", 98 | "type": "commit", 99 | "url": "https://api.github.com/repos/actions/checkout/git/commits/1e31de5234b9f8995739874a8ce0492dc87873e2", 100 | }, 101 | "ref": "refs/tags/v4.0.0", 102 | "url": "https://api.github.com/repos/actions/checkout/git/refs/tags/v4.0.0", 103 | } 104 | 105 | tests := []struct { 106 | name string 107 | args args 108 | want interface{} 109 | wantErr bool 110 | }{ 111 | {"pass", args{"actions/checkout", "v4.0.0", gitHubToken}, want, false}, 112 | {"pass", args{"actions/checkout", "v4.0.999", gitHubToken}, nil, true}, 113 | } 114 | for _, tt := range tests { 115 | t.Run(tt.name, func(t *testing.T) { 116 | got, err := getHash(tt.args.action, tt.args.tag, tt.args.gitHubToken) 117 | if (err != nil) != tt.wantErr { 118 | t.Errorf("getHash() error = %v, wantErr %v", err, tt.wantErr) 119 | return 120 | } 121 | if !reflect.DeepEqual(got, tt.want) { 122 | t.Errorf("getHash() got = %v, want %v", got, tt.want) 123 | } 124 | }) 125 | } 126 | } 127 | 128 | func Test_getPayload(t *testing.T) { 129 | t.Parallel() 130 | 131 | type args struct { 132 | action string 133 | gitHubToken string 134 | days *uint 135 | } 136 | 137 | var days uint = 0 138 | var ninety uint = 90 139 | 140 | daysMap := map[string]interface{}{ 141 | "html_url": "https://github.com/JamesWoolfenden/action-pike/releases/tag/v0.1.3", 142 | "id": 81460196, 143 | "created_at": "2022-10-29T11:25:25Z", 144 | "url": "https://api.github.com/repos/JamesWoolfenden/action-pike/releases/81460196", 145 | "node_id": "RE_kwDOIVF07c4E2vvk", 146 | "prerelease": "false", 147 | "tarball_url": "https://api.github.com/repos/JamesWoolfenden/action-pike/tarball/v0.1.3", 148 | "target_commitish": "master", 149 | "name": "Initial Release", 150 | "zipball_url": "https://api.github.com/repos/JamesWoolfenden/action-pike/zipball/v0.1.3", 151 | "assets_url": "https://api.github.com/repos/JamesWoolfenden/action-pike/releases/81460196/assets", 152 | "upload_url": "https://uploads.github.com/repos/JamesWoolfenden/action-pike/releases/81460196/assets{?name,label}", 153 | "tag_name": "v0.1.3", 154 | "draft": "false", 155 | "published_at": "2022-10-29T15:17:57Z", 156 | } 157 | 158 | tests := []struct { 159 | name string 160 | args args 161 | want interface{} 162 | wantErr bool 163 | }{ 164 | {"pass", args{"JamesWoolfenden/action-pike", gitHubToken, &days}, daysMap, false}, 165 | {"pass", args{"JamesWoolfenden/action-pike", gitHubToken, &ninety}, daysMap, false}, 166 | } 167 | 168 | for _, tt := range tests { 169 | tt := tt 170 | t.Run(tt.name, func(t *testing.T) { 171 | t.Parallel() 172 | got, err := getPayload(tt.args.action, tt.args.gitHubToken, tt.args.days) 173 | if (err != nil) != tt.wantErr { 174 | t.Errorf("getPayload() error = %v, wantErr %v", err, tt.wantErr) 175 | return 176 | } 177 | 178 | gotMap := got.(map[string]interface{}) 179 | wantMap := tt.want.(map[string]interface{}) 180 | 181 | if !reflect.DeepEqual(gotMap["created_at"], wantMap["created_at"]) { 182 | t.Errorf("getPayload() got = %v, want %v", got, tt.want) 183 | } 184 | }) 185 | } 186 | } 187 | 188 | func TestFlags_GetGHA(t *testing.T) { 189 | type fields struct { 190 | File string 191 | Directory string 192 | GitHubToken string 193 | Days uint 194 | DryRun bool 195 | } 196 | 197 | type args struct { 198 | matches []os.DirEntry 199 | ghat []os.DirEntry 200 | } 201 | 202 | duffDir := fields{"", "nothere", gitHubToken, 0, false} 203 | noMatches, _ := os.ReadDir(duffDir.Directory) 204 | 205 | noWorkflowsDir := fields{"", "./testdata/noworkflows", gitHubToken, 0, false} 206 | noWorkflows, _ := os.ReadDir(noWorkflowsDir.Directory) 207 | 208 | noWorkflowsWithDir := fields{"", "./testdata/noworkflowswithdir", gitHubToken, 0, false} 209 | noWorkflowsWithDirContents, _ := os.ReadDir(noWorkflowsWithDir.Directory) 210 | 211 | var nothing []string 212 | tests := []struct { 213 | name string 214 | fields fields 215 | args args 216 | want []string 217 | }{ 218 | {"no matches", duffDir, args{noMatches, nil}, nothing}, 219 | {"no workflows", noWorkflowsDir, args{noWorkflows, nil}, nil}, 220 | {"no workflows with dir", noWorkflowsWithDir, args{noWorkflowsWithDirContents, nil}, nil}, 221 | } 222 | for _, tt := range tests { 223 | t.Run(tt.name, func(t *testing.T) { 224 | myFlags := &Flags{ 225 | File: tt.fields.File, 226 | Directory: tt.fields.Directory, 227 | GitHubToken: tt.fields.GitHubToken, 228 | Days: tt.fields.Days, 229 | DryRun: tt.fields.DryRun, 230 | } 231 | got := myFlags.GetGHA() 232 | 233 | if !reflect.DeepEqual(got, tt.want) { 234 | t.Errorf("GetGHA() got = %v, want %v", got, tt.want) 235 | } 236 | }) 237 | } 238 | } 239 | 240 | func TestGetLatestTag(t *testing.T) { 241 | t.Parallel() 242 | type args struct { 243 | action string 244 | gitHubToken string 245 | } 246 | 247 | latest := "34bf44973c4f415bd3e791728b630e5d110a2244" 248 | 249 | tests := []struct { 250 | name string 251 | args args 252 | want string 253 | wantErr bool 254 | }{ 255 | {"Pass", args{"jameswoolfenden/terraform-azurerm-diskencryptionset", gitHubToken}, latest, false}, 256 | {"Fail", args{"jameswoolfenden/terraform-azurerm-guff", gitHubToken}, "", true}, 257 | } 258 | for _, tt := range tests { 259 | tt := tt 260 | t.Run(tt.name, func(t *testing.T) { 261 | t.Parallel() 262 | got, err := GetLatestTag(tt.args.action, tt.args.gitHubToken) 263 | if (err != nil) != tt.wantErr { 264 | t.Errorf("GetLatestTag() error = %v, wantErr %v", err, tt.wantErr) 265 | return 266 | } 267 | 268 | if got == nil && tt.want != "" { 269 | t.Errorf("GetLatestTag() got = nil, want %v", tt.want) 270 | return 271 | } 272 | 273 | if (got == nil) == (tt.want == "") { 274 | return 275 | } 276 | 277 | returned := got.(map[string]interface{}) 278 | commit := returned["commit"].(map[string]interface{}) 279 | hash := commit["sha"].(string) 280 | if hash != tt.want { 281 | t.Errorf("GetLatestTag() got = %v, want %v", hash, tt.want) 282 | } 283 | }) 284 | } 285 | } 286 | 287 | func TestFlags_UpdateGHAS(t *testing.T) { 288 | t.Parallel() 289 | 290 | type fields struct { 291 | File string 292 | Directory string 293 | GitHubToken string 294 | Days uint 295 | DryRun bool 296 | Entries []string 297 | Update bool 298 | } 299 | 300 | tests := []struct { 301 | name string 302 | fields fields 303 | wantErr bool 304 | }{ 305 | {"Pass file", 306 | fields{"./testdata/gha/.github/workflows/test.yml", "", gitHubToken, 0, true, []string{"./testdata/gha/.github/workflows/test.yml"}, true}, false}, 307 | {"Pass file not dry", 308 | fields{"./testdata/gha/.github/workflows/test.yml", "", gitHubToken, 0, false, []string{"./testdata/gha/.github/workflows/test.yml"}, true}, false}, 309 | {"Pass dir", 310 | fields{"", "./testdata/gha/.github/workflows", gitHubToken, 0, true, []string{"./testdata/gha/.github/workflows/test.yml"}, true}, false}, 311 | } 312 | 313 | for _, tt := range tests { 314 | tt := tt 315 | t.Run(tt.name, func(t *testing.T) { 316 | t.Parallel() 317 | myFlags := &Flags{ 318 | File: tt.fields.File, 319 | Directory: tt.fields.Directory, 320 | GitHubToken: tt.fields.GitHubToken, 321 | Days: tt.fields.Days, 322 | DryRun: tt.fields.DryRun, 323 | Entries: tt.fields.Entries, 324 | Update: tt.fields.Update, 325 | } 326 | if err := myFlags.UpdateGHAS(); (err != nil) != tt.wantErr { 327 | t.Errorf("UpdateGHAS() error = %v, wantErr %v", err, tt.wantErr) 328 | } 329 | }) 330 | } 331 | } 332 | 333 | func TestFlags_UpdateGHA(t *testing.T) { 334 | t.Parallel() 335 | type fields struct { 336 | File string 337 | Directory string 338 | GitHubToken string 339 | Days uint 340 | DryRun bool 341 | Entries []string 342 | Update bool 343 | ContinueOnError bool 344 | } 345 | 346 | type args struct { 347 | file string 348 | } 349 | 350 | tests := []struct { 351 | name string 352 | fields fields 353 | args args 354 | wantErr bool 355 | }{ 356 | {name: "Pass file", 357 | fields: fields{File: "./testdata/gha/.github/workflows/test.yml", GitHubToken: gitHubToken, DryRun: true, Entries: []string{"./testdata/gha/.github/workflows/test.yml"}, Update: true}, 358 | args: args{"./testdata/gha/.github/workflows/test.yml"}}, 359 | {name: "No such file", 360 | fields: fields{File: "./testdata/gha/.github/workflows/guff.yml", GitHubToken: gitHubToken, DryRun: true, Entries: []string{"./testdata/gha/.github/workflows/test.yml"}, Update: true}, 361 | args: args{"./testdata/gha/.github/workflows/guff.yml"}, 362 | wantErr: true}, 363 | {name: "Faulty GHA", 364 | fields: fields{File: "./testdata/faulty/.github/workflows/test.yml", GitHubToken: gitHubToken, DryRun: true, Entries: []string{"./testdata/faulty/.github/workflows/test.yml"}, Update: true}, 365 | args: args{file: "./testdata/faulty/.github/workflows/test.yml"}, 366 | wantErr: true}, 367 | {name: "Faulty GHA continue", 368 | fields: fields{File: "./testdata/faulty/.github/workflows/test.yml", GitHubToken: gitHubToken, DryRun: true, Entries: []string{"./testdata/faulty/.github/workflows/test.yml"}, Update: true, ContinueOnError: true}, 369 | args: args{file: "./testdata/faulty/.github/workflows/test.yml"}}, 370 | { 371 | name: "Empty entries", 372 | fields: fields{ 373 | Entries: []string{}, 374 | GitHubToken: gitHubToken, 375 | }, 376 | wantErr: true, 377 | }, 378 | { 379 | name: "Invalid file path", 380 | fields: fields{ 381 | Entries: []string{"./testdata/nonexistent/workflow.yml"}, 382 | GitHubToken: gitHubToken, 383 | }, 384 | wantErr: true, 385 | }, 386 | } 387 | for _, tt := range tests { 388 | tt := tt 389 | t.Run(tt.name, func(t *testing.T) { 390 | t.Parallel() 391 | myFlags := &Flags{ 392 | File: tt.fields.File, 393 | Directory: tt.fields.Directory, 394 | GitHubToken: tt.fields.GitHubToken, 395 | Days: tt.fields.Days, 396 | DryRun: tt.fields.DryRun, 397 | Entries: tt.fields.Entries, 398 | Update: tt.fields.Update, 399 | ContinueOnError: tt.fields.ContinueOnError, 400 | } 401 | if err := myFlags.UpdateGHA(tt.args.file); (err != nil) != tt.wantErr { 402 | t.Errorf("UpdateGHA() error = %v, wantErr %v", err, tt.wantErr) 403 | } 404 | }) 405 | } 406 | } 407 | 408 | func setupSuite(tb testing.TB) func(tb testing.TB) { 409 | log.Info().Msgf("setup suite %s", tb.Name()) 410 | testPath, _ := filepath.Abs("./testdata/empty") 411 | _ = os.Mkdir(testPath, os.ModePerm) 412 | _ = os.Mkdir("./testdata/.terraform/", os.ModePerm) 413 | _ = os.Mkdir("./testdata/.git/", os.ModePerm) 414 | 415 | return func(tb testing.TB) { 416 | log.Info().Msg("teardown suite") 417 | _ = os.RemoveAll(testPath) 418 | _ = os.RemoveAll("./testdata/.terraform/") 419 | _ = os.RemoveAll("./testdata/.git/") 420 | 421 | } 422 | } 423 | 424 | func TestGetFiles(t *testing.T) { 425 | t.Parallel() 426 | 427 | //teardownSuite := setupSuite(t) 428 | //defer teardownSuite(t) 429 | 430 | tests := []struct { 431 | name string 432 | dir string 433 | want int 434 | wantErr bool 435 | }{ 436 | {"Valid directory", "./testdata/gha", 1, false}, 437 | {"Empty directory", "./testdata/empty", 0, false}, 438 | {"Non-existent directory", "./testdata/nonexistent", 0, true}, 439 | {"Directory with .terraform", "./testdata/.terraform", 0, false}, 440 | {"Directory with .git", "./testdata/.git", 0, false}, 441 | } 442 | 443 | for _, tt := range tests { 444 | tt := tt 445 | t.Run(tt.name, func(t *testing.T) { 446 | t.Parallel() 447 | teardownSuite := setupSuite(t) 448 | defer teardownSuite(t) 449 | got, err := GetFiles(tt.dir) 450 | if (err != nil) != tt.wantErr { 451 | t.Errorf("GetFiles() error = %v, wantErr %v", err, tt.wantErr) 452 | return 453 | } 454 | if !tt.wantErr && len(got) != tt.want { 455 | t.Errorf("GetFiles() got = %v files, want %v", len(got), tt.want) 456 | } 457 | }) 458 | } 459 | } 460 | 461 | func TestReadFilesError(t *testing.T) { 462 | t.Parallel() 463 | 464 | testErr := fmt.Errorf("test error") 465 | err := &readFilesError{err: testErr} 466 | expected := "failed to read files: test error" 467 | 468 | if err.Error() != expected { 469 | t.Errorf("readFilesError.Error() = %v, want %v", err.Error(), expected) 470 | } 471 | } 472 | 473 | func TestAbsolutePathError(t *testing.T) { 474 | t.Parallel() 475 | 476 | testErr := fmt.Errorf("test error") 477 | testDir := "/test/dir" 478 | err := &absolutePathError{directory: testDir, err: testErr} 479 | expected := "failed to get absolute path: test error /test/dir " 480 | 481 | if err.Error() != expected { 482 | t.Errorf("absolutePathError.Error() = %v, want %v", err.Error(), expected) 483 | } 484 | } 485 | 486 | func TestGetGithubBody_EdgeCases(t *testing.T) { 487 | t.Parallel() 488 | 489 | tests := []struct { 490 | name string 491 | gitHubToken string 492 | url string 493 | wantErr bool 494 | }{ 495 | { 496 | name: "Invalid URL format", 497 | gitHubToken: gitHubToken, 498 | url: "not-a-url", 499 | wantErr: true, 500 | }, 501 | { 502 | name: "Empty URL", 503 | gitHubToken: gitHubToken, 504 | url: "", 505 | wantErr: true, 506 | }, 507 | { 508 | name: "Invalid JSON response", 509 | gitHubToken: gitHubToken, 510 | url: "https://api.github.com/invalid-endpoint", 511 | wantErr: true, 512 | }, 513 | } 514 | 515 | for _, tt := range tests { 516 | tt := tt 517 | t.Run(tt.name, func(t *testing.T) { 518 | t.Parallel() 519 | _, err := GetGithubBody(tt.gitHubToken, tt.url) 520 | if (err != nil) != tt.wantErr { 521 | t.Errorf("GetGithubBody() error = %v, wantErr %v", err, tt.wantErr) 522 | } 523 | }) 524 | } 525 | } 526 | 527 | func TestGetPayload_ErrorCases(t *testing.T) { 528 | t.Parallel() 529 | 530 | var days uint = 30 531 | tests := []struct { 532 | name string 533 | action string 534 | gitHubToken string 535 | days *uint 536 | wantErr bool 537 | }{ 538 | { 539 | name: "Empty action", 540 | action: "", 541 | gitHubToken: gitHubToken, 542 | days: &days, 543 | wantErr: true, 544 | }, 545 | { 546 | name: "Invalid action format", 547 | action: "invalid-format", 548 | gitHubToken: gitHubToken, 549 | days: &days, 550 | wantErr: true, 551 | }, 552 | { 553 | name: "Nil days pointer", 554 | action: "actions/checkout", 555 | gitHubToken: gitHubToken, 556 | days: nil, 557 | wantErr: true, 558 | }, 559 | } 560 | 561 | for _, tt := range tests { 562 | tt := tt 563 | t.Run(tt.name, func(t *testing.T) { 564 | t.Parallel() 565 | _, err := getPayload(tt.action, tt.gitHubToken, tt.days) 566 | if (err != nil) != tt.wantErr { 567 | t.Errorf("getPayload() error = %v, wantErr %v", err, tt.wantErr) 568 | } 569 | }) 570 | } 571 | } 572 | -------------------------------------------------------------------------------- /src/core/modules.go: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path" 7 | "path/filepath" 8 | "strings" 9 | 10 | "github.com/go-git/go-git/v5" 11 | "github.com/go-git/go-git/v5/storage/memory" 12 | "github.com/hashicorp/hcl/v2" 13 | "github.com/hashicorp/hcl/v2/hclwrite" 14 | "github.com/rs/zerolog/log" 15 | "github.com/sergi/go-diff/diffmatchpatch" 16 | "github.com/zclconf/go-cty/cty" 17 | "golang.org/x/mod/semver" 18 | ) 19 | 20 | func (myFlags *Flags) UpdateModule(file string) error { 21 | 22 | var version string 23 | var newValue string 24 | 25 | src, err := os.ReadFile(file) 26 | if err != nil { 27 | return fmt.Errorf("failed to read %s", file) 28 | } 29 | 30 | inFile, _ := hclwrite.ParseConfig(src, "", hcl.Pos{Line: 1, Column: 1}) 31 | outFile := hclwrite.NewEmptyFile() 32 | 33 | newBody := outFile.Body() 34 | root := inFile.Body() 35 | 36 | for _, block := range root.Blocks() { 37 | if block.Type() == "module" { 38 | version = GetVersion(block) 39 | 40 | source := GetStringValue(block, "source") 41 | 42 | block.Body().RemoveAttribute("version") 43 | 44 | myType, err := myFlags.GetType(source) 45 | 46 | if err != nil { 47 | log.Info().Msgf("source type failure %s", source) 48 | } else { 49 | newValue, version, err = myFlags.UpdateSource(source, myType, version) 50 | if err != nil { 51 | log.Info().Msgf("failed to update module source %s", err) 52 | } 53 | block.Body().SetAttributeValue("source", cty.StringVal(newValue)) 54 | } 55 | } 56 | 57 | newBody.AppendBlock(block) 58 | } 59 | 60 | var differ bool 61 | 62 | temp := string(outFile.Bytes()) 63 | 64 | if version != "" { 65 | find := "\"" + newValue + "\"" 66 | replacement := " source = " + find + " #" + version 67 | 68 | lines := strings.Split(temp, "\n") 69 | 70 | for i, line := range lines { 71 | if strings.Contains(line, find) { 72 | lines[i] = replacement 73 | break 74 | } 75 | } 76 | 77 | temp = strings.Join(lines, "\n") 78 | } 79 | 80 | if string(src) != temp { 81 | differ = true 82 | } 83 | 84 | dmp := diffmatchpatch.New() 85 | diffs := dmp.DiffMain(string(src), temp, false) 86 | 87 | if differ { 88 | fmt.Println(dmp.DiffPrettyText(diffs)) 89 | } 90 | 91 | if differ && !myFlags.DryRun { 92 | err := os.WriteFile(file, []byte(temp), 0666) 93 | if err != nil { 94 | log.Info().Msgf("failed to write %s", file) 95 | } 96 | } 97 | 98 | return nil 99 | } 100 | 101 | func GetVersion(block *hclwrite.Block) string { 102 | version := GetStringValue(block, "version") 103 | if version == "" { 104 | return "" 105 | } 106 | 107 | constraints := []string{"=", "!", ">", ">", "~"} 108 | 109 | for _, constraint := range constraints { 110 | if strings.Contains(version, constraint) { 111 | version = "" 112 | log.Info().Msg("constraints not valid, using latest") 113 | continue 114 | } 115 | } 116 | 117 | if !strings.Contains(version, "v") && version != "" { 118 | version = "v" + version 119 | } 120 | 121 | return version 122 | } 123 | 124 | func GetStringValue(block *hclwrite.Block, attribute string) string { 125 | var Value string 126 | version := block.Body().GetAttribute(attribute) 127 | 128 | if (version != nil) && (len(version.Expr().BuildTokens(nil)) == 3) { 129 | Value = string(version.Expr().BuildTokens(nil)[1].Bytes) 130 | } 131 | return Value 132 | } 133 | 134 | func (myFlags *Flags) UpdateModules() error { 135 | 136 | terraform, err := myFlags.GetTF() 137 | 138 | if err != nil { 139 | return err 140 | } 141 | 142 | // contains a module? 143 | for _, file := range terraform { 144 | err = myFlags.UpdateModule(file) 145 | if err != nil { 146 | return err 147 | } 148 | } 149 | 150 | return nil 151 | } 152 | 153 | func (myFlags *Flags) GetTF() ([]string, error) { 154 | var terraform []string 155 | 156 | for _, match := range myFlags.Entries { 157 | //for each file that is a terraform file 158 | if path.Ext(match) == ".tf" { 159 | terraform = append(terraform, match) 160 | } 161 | } 162 | 163 | return terraform, nil 164 | } 165 | 166 | func (myFlags *Flags) GetType(module string) (string, error) { 167 | var moduleType string 168 | 169 | // handle local path 170 | absPath, _ := filepath.Abs(module) 171 | _, err := os.Stat(absPath) 172 | 173 | if err == nil { 174 | return "local", nil 175 | } 176 | 177 | if strings.Contains(module, "bitbucket.org") { 178 | return "bitbucket", nil 179 | } 180 | 181 | if strings.Contains(module, "s3::") { 182 | return "s3", nil 183 | } 184 | 185 | if strings.Contains(module, "gcs::") { 186 | return "gcs", nil 187 | } 188 | 189 | if strings.Contains(module, ".zip") || strings.Contains(module, "archive=") { 190 | return "archive", nil 191 | } 192 | 193 | // gitHub registry format and sub dirs 194 | splitter := strings.Split(module, "/") 195 | 196 | if len(splitter) == 3 && !(strings.Contains(module, "git::") || strings.Contains(module, "https:")) { 197 | if strings.Contains(module, "github.com") { 198 | return "github", nil 199 | } 200 | 201 | return "registry", nil 202 | } 203 | 204 | if strings.Contains(module, "depth=") { 205 | return "shallow", nil 206 | } 207 | 208 | if strings.Contains(module, "git::") { 209 | return "git", nil 210 | } 211 | 212 | if strings.Contains(module, "hg::") { 213 | return "mercurial", nil 214 | } 215 | 216 | if strings.Contains(module, "//") { 217 | temp := strings.Split(module, "//")[0] 218 | return myFlags.GetType(temp) 219 | } 220 | 221 | if _, err := os.Stat(module); os.IsNotExist(err) { 222 | return "local", fmt.Errorf("localpath not found %s", module) 223 | } 224 | 225 | return moduleType, err 226 | } 227 | 228 | func (myFlags *Flags) UpdateSource(module string, moduleType string, version string) (string, string, error) { 229 | 230 | var newModule string 231 | 232 | var hash string 233 | 234 | var err error 235 | 236 | switch moduleType { 237 | case "git": 238 | { 239 | newModule := strings.TrimPrefix(module, "git::") 240 | 241 | splitter := strings.Split(newModule, "?ref=") 242 | 243 | root := splitter[0] 244 | 245 | if len(splitter) > 1 { 246 | version = splitter[1] 247 | } 248 | 249 | if myFlags.Update { 250 | if strings.Contains(newModule, "github.com") { 251 | hash, version, err := myFlags.GetGithubLatestHash(newModule) 252 | if err != nil { 253 | return "", "", err 254 | } 255 | 256 | return "git::" + root + "?ref=" + hash, version, nil 257 | } else { 258 | repo, err := git.Clone(memory.NewStorage(), nil, &git.CloneOptions{ 259 | URL: strings.TrimRight(module, ".git"), 260 | }) 261 | 262 | if err != nil { 263 | return "", "", fmt.Errorf("failed to clone %s", newModule) 264 | } 265 | 266 | ref, err := repo.Head() 267 | if err != nil { 268 | return "", "", err 269 | } 270 | log.Print(ref) 271 | } 272 | 273 | // get latest hash for root 274 | log.Print(root) 275 | } else { 276 | if strings.Contains(newModule, "github.com") { 277 | if version != "" { 278 | hash, err = myFlags.GetGithubHash( 279 | strings.TrimPrefix(newModule, "https://"), 280 | version, 281 | ) 282 | if err != nil { 283 | return "", "", err 284 | } 285 | } else { 286 | hash, version, err = myFlags.GetGithubLatestHash(newModule) 287 | if err != nil { 288 | return "", "", err 289 | } 290 | } 291 | return "git::" + root + "?ref=" + hash, version, nil 292 | } else { 293 | log.Info().Msgf("git != github") 294 | } 295 | } 296 | } 297 | 298 | case "registry": 299 | { 300 | var subDir string 301 | 302 | subDirs := strings.Split(module, "//") 303 | 304 | if len(subDirs) == 2 { 305 | subDir = subDirs[1] 306 | module = subDirs[0] 307 | } 308 | 309 | splits := strings.Split(module, "/") 310 | 311 | if len(splits) != 3 { 312 | return "", "", fmt.Errorf("registry format should split 3 ways") 313 | } 314 | 315 | //e.g. jameswoolfenden/terraform-http-ip 316 | newModule := "github.com" + "/" + splits[0] + "/" + "terraform" + "-" + splits[2] + "-" + splits[1] + ".git" 317 | 318 | if subDir == "" { 319 | return myFlags.UpdateGithubSource(version, newModule) 320 | } else { 321 | return myFlags.WithSubDir(version, newModule, subDir) 322 | } 323 | } 324 | 325 | case "github": 326 | { 327 | subDirs := strings.Split(module, "//") 328 | if len(subDirs) == 2 { 329 | subDir := subDirs[1] 330 | root := subDirs[0] 331 | 332 | // e.g. jameswoolfenden/terraform-http-ip 333 | newModule := root + ".git" 334 | 335 | return myFlags.WithSubDir(version, newModule, subDir) 336 | } 337 | 338 | newModule = module + ".git" 339 | return myFlags.UpdateGithubSource(version, newModule) 340 | } 341 | 342 | case "local", "shallow", "archive", "s3", "gcs", "mercurial": 343 | { 344 | log.Info().Msgf("module source is %s of type %s and cannot be updated", module, moduleType) 345 | return module, version, nil 346 | } 347 | 348 | default: 349 | { 350 | log.Info().Msgf("unknown module type encountered %s", moduleType) 351 | } 352 | } 353 | 354 | return newModule, version, nil 355 | } 356 | 357 | func (myFlags *Flags) WithSubDir(version string, newModule string, subdir string) (string, string, error) { 358 | url, version, err := myFlags.UpdateGithubSource(version, newModule) 359 | 360 | urlsplit := strings.Split(url, ".git") 361 | newUrl := urlsplit[0] + ".git" + "//" + subdir + urlsplit[1] 362 | 363 | return newUrl, version, err 364 | } 365 | 366 | func (myFlags *Flags) UpdateGithubSource(version string, newModule string) (string, string, error) { 367 | var hash string 368 | 369 | var err error 370 | 371 | if myFlags.Update { 372 | hash, version, err = myFlags.GetGithubLatestHash(newModule) 373 | if err != nil { 374 | return "", "", err 375 | } 376 | } else { 377 | if version != "" { 378 | hash, err = myFlags.GetGithubHash(newModule, version) 379 | if err != nil { 380 | return "", "", err 381 | } 382 | } else { 383 | hash, version, err = myFlags.GetGithubLatestHash(newModule) 384 | if err != nil { 385 | return "", "", err 386 | } 387 | } 388 | } 389 | 390 | return "git::https://" + newModule + "?ref=" + hash, version, nil 391 | } 392 | 393 | func (myFlags *Flags) GetGithubLatestHash(newModule string) (string, string, error) { 394 | name := strings.Split(newModule, "github.com/") 395 | 396 | if len(name) < 2 { 397 | return "", "", fmt.Errorf("modules string doesnt contain github.com") 398 | } 399 | 400 | action := strings.Split(name[1], ".git") 401 | if len(action) < 2 { 402 | return "", "", fmt.Errorf("modules string doesnt end in .git") 403 | } 404 | 405 | payload, err := GetLatestTag(action[0], myFlags.GitHubToken) 406 | 407 | if err != nil { 408 | return "", "", err 409 | } 410 | 411 | assertedPayload, ok := payload.(map[string]interface{}) 412 | 413 | if !ok { 414 | return "", "", fmt.Errorf("type assertion failed") 415 | } 416 | 417 | version, ok := assertedPayload["name"].(string) 418 | 419 | if !ok { 420 | return "", "", fmt.Errorf("type assertion failed") 421 | } 422 | 423 | commit := assertedPayload["commit"].(map[string]interface{}) 424 | 425 | hash := commit["sha"].(string) 426 | 427 | return hash, version, nil 428 | } 429 | 430 | func (myFlags *Flags) GetGithubHash(newModule string, tag string) (string, error) { 431 | var err error 432 | 433 | var hash string 434 | 435 | var url string 436 | 437 | var payload interface{} 438 | 439 | name := strings.Split(newModule, "github.com/") 440 | action := strings.Split(name[1], ".git") 441 | 442 | valid := semver.IsValid(tag) 443 | 444 | if valid { 445 | url = "https://api.github.com/repos/" + action[0] + "/git/ref/tags/" + tag 446 | payload, err = GetGithubBody(myFlags.GitHubToken, url) 447 | 448 | if err != nil { 449 | // retry as version is truncated 450 | if strings.Count(tag, ".") == 1 { 451 | tag = tag + ".0" 452 | url = "https://api.github.com/repos/" + action[0] + "/git/ref/tags/" + tag 453 | payload, err = GetGithubBody(myFlags.GitHubToken, url) 454 | if err != nil { 455 | log.Info().Msgf("failed to find tag %s", tag) 456 | return "", err 457 | } 458 | } else { 459 | return "", err 460 | } 461 | } else { 462 | log.Info().Msgf("failed to understand %s", tag) 463 | } 464 | 465 | assertedPayload := payload.(map[string]interface{}) 466 | 467 | object := assertedPayload["object"].(map[string]interface{}) 468 | 469 | hash = object["sha"].(string) 470 | } else { 471 | if len(tag) == 40 || len(tag) == 7 { 472 | hash = tag 473 | } else { 474 | return "", fmt.Errorf("supplied hash is not a short or a long hash") 475 | } 476 | } 477 | 478 | return hash, err 479 | } 480 | -------------------------------------------------------------------------------- /src/core/modules_test.go: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | import ( 4 | "fmt" 5 | "reflect" 6 | "testing" 7 | ) 8 | 9 | func TestFlags_GetType(t *testing.T) { 10 | t.Parallel() 11 | type fields struct { 12 | File string 13 | Directory string 14 | GitHubToken string 15 | Days uint 16 | DryRun bool 17 | Entries []string 18 | Update bool 19 | } 20 | 21 | type args struct { 22 | module string 23 | } 24 | 25 | //goland:noinspection HttpUrlsUsage 26 | tests := []struct { 27 | name string 28 | fields fields 29 | args args 30 | wantType string 31 | wantErr bool 32 | }{ 33 | {"Local paths", fields{}, args{"./testdata"}, "local", false}, 34 | {"Local paths not found", fields{}, args{"./somewhere"}, "local", true}, 35 | 36 | {"Terraform Registry", fields{}, args{"jameswoolfenden/http/ip"}, "registry", false}, 37 | {"Terraform Registry fail", fields{}, args{"jameswoolfenden/http/ip/duff"}, "local", true}, 38 | {"github", fields{}, args{"github.com/jameswoolfenden/terraform-http-ip"}, "github", false}, 39 | 40 | {"git", fields{}, args{"git::https://github.com/terraform-aws-modules/terraform-aws-memory-db.git"}, "git", false}, 41 | {"git query string", fields{}, args{"git::https://github.com/terraform-aws-modules/terraform-aws-memory-db.git"}, "git", false}, 42 | {"git query string", fields{}, args{"git::ssh://github.com/terraform-aws-modules/terraform-aws-memory-db"}, "git", false}, 43 | 44 | // I dearly wanted to use that name 45 | {"Bitbucket", fields{}, args{"bitbucket.org/hashicorp/terraform-consul-aws"}, "bitbucket", false}, 46 | 47 | {"Shallow", fields{}, args{"git::https://github.com/terraform-aws-modules/terraform-aws-memory-db.git?depth=1"}, "shallow", false}, // 48 | 49 | {"Mercurial repositories", fields{}, args{"hg::http://example.com/vpc.hg"}, "mercurial", false}, 50 | // 51 | {"archive", fields{}, args{"https://example.com/vpc-module.zip"}, "archive", false}, 52 | {"archive", fields{}, args{"https://example.com/vpc-module?archive=zip"}, "archive", false}, 53 | 54 | {"S3 buckets", fields{}, args{"s3::https://s3-eu-west-1.amazonaws.com/examplecorp-terraform-modules/vpc.zip"}, "s3", false}, 55 | {"GCS buckets", fields{}, args{"gcs::https://www.googleapis.com/storage/v1/modules/foomodule.zip"}, "gcs", false}, 56 | 57 | {"Modules in Package Sub-directories", fields{}, args{"hashicorp/consul/aws//modules/consul-cluster"}, "registry", false}, 58 | {"Modules 2", fields{}, args{"git::https://example.com/network.git//modules/vpc"}, "git", false}, 59 | } 60 | for _, tt := range tests { 61 | tt := tt 62 | t.Run(tt.name, func(t *testing.T) { 63 | t.Parallel() 64 | myFlags := &Flags{ 65 | File: tt.fields.File, 66 | Directory: tt.fields.Directory, 67 | GitHubToken: tt.fields.GitHubToken, 68 | Days: tt.fields.Days, 69 | DryRun: tt.fields.DryRun, 70 | Entries: tt.fields.Entries, 71 | Update: tt.fields.Update, 72 | } 73 | got, err := myFlags.GetType(tt.args.module) 74 | if (err != nil) != tt.wantErr { 75 | t.Errorf("GetType() error = %v, wantErr %v", err, tt.wantErr) 76 | 77 | return 78 | } 79 | 80 | if got != tt.wantType { 81 | t.Errorf("GetType() got = %v, want %v", got, tt.wantType) 82 | } 83 | }) 84 | } 85 | } 86 | 87 | func TestFlags_UpdateSource(t *testing.T) { 88 | type fields struct { 89 | File string 90 | Directory string 91 | GitHubToken string 92 | Days uint 93 | DryRun bool 94 | Entries []string 95 | Update bool 96 | } 97 | type args struct { 98 | module string 99 | moduleType string 100 | version string 101 | } 102 | //goland:noinspection HttpUrlsUsage 103 | tests := []struct { 104 | name string 105 | fields fields 106 | args args 107 | want string 108 | want1 string 109 | wantErr bool 110 | }{ 111 | {"Local paths", fields{}, args{"./testdata", "local", ""}, "./testdata", "", false}, 112 | {"Local paths not found", fields{}, args{"./somewhere", "local", ""}, "./somewhere", "", false}, 113 | 114 | {"github", 115 | fields{"", "", gitHubToken, 0, false, nil, true}, 116 | args{"github.com/hashicorp/terraform-aws-consul", "github", ""}, 117 | "git::https://github.com/hashicorp/terraform-aws-consul.git?ref=e9ceb573687c3d28516c9e3714caca84db64a766", 118 | "v0.11.0", 119 | false}, 120 | {"Terraform Registry fail", 121 | fields{}, 122 | args{"jameswoolfenden/http/ip/duff", "registry", ""}, 123 | "", 124 | "", 125 | true}, 126 | {"git", 127 | fields{"", "", gitHubToken, 0, false, nil, false}, 128 | args{"git::https://github.com/terraform-aws-modules/terraform-aws-memory-db.git", "git", ""}, 129 | "git::https://github.com/terraform-aws-modules/terraform-aws-memory-db.git?ref=2c24bd2b005d804cddaa4a09aa39a5a82d0ee9fb", 130 | "v2.3.0", false}, 131 | {"git update", 132 | fields{"", "", gitHubToken, 0, false, nil, true}, 133 | args{"git::https://github.com/terraform-aws-modules/terraform-aws-memory-db.git", "git", ""}, 134 | "git::https://github.com/terraform-aws-modules/terraform-aws-memory-db.git?ref=2c24bd2b005d804cddaa4a09aa39a5a82d0ee9fb", 135 | "v2.3.0", false}, 136 | {"git version", 137 | fields{"", "", gitHubToken, 0, false, nil, false}, 138 | args{"git::https://github.com/terraform-aws-modules/terraform-aws-memory-db.git?ref=v1.0.0", "git", ""}, 139 | "git::https://github.com/terraform-aws-modules/terraform-aws-memory-db.git?ref=c1a0698ae1ae4ced03399809ef3e0253b07c44a9", 140 | "v1.0.0", false}, 141 | {"git version update", 142 | fields{"", "", gitHubToken, 0, false, nil, true}, 143 | args{"git::https://github.com/terraform-aws-modules/terraform-aws-memory-db.git?ref=v1.0.0", "git", ""}, 144 | "git::https://github.com/terraform-aws-modules/terraform-aws-memory-db.git?ref=2c24bd2b005d804cddaa4a09aa39a5a82d0ee9fb", 145 | "v2.3.0", false}, 146 | {"git version missing", 147 | fields{"", "", gitHubToken, 0, false, nil, false}, 148 | args{"git::https://github.com/terraform-aws-modules/terraform-aws-memory-db.git?ref=v1.2.0", "git", ""}, 149 | "", "", true}, 150 | {"git hash", 151 | fields{"", "", gitHubToken, 0, false, nil, false}, 152 | args{"git::https://github.com/terraform-aws-modules/terraform-aws-memory-db.git?ref=c6d56c1", "git", ""}, 153 | "git::https://github.com/terraform-aws-modules/terraform-aws-memory-db.git?ref=c6d56c1", "c6d56c1", false}, 154 | {name: "git hash update", 155 | fields: fields{"", "", gitHubToken, 0, false, nil, true}, 156 | args: args{"git::https://github.com/terraform-aws-modules/terraform-aws-memory-db.git?ref=93facd14e9e3a66704d84a0236a8a3b813f047be", "git", ""}, 157 | want: "git::https://github.com/terraform-aws-modules/terraform-aws-memory-db.git?ref=2c24bd2b005d804cddaa4a09aa39a5a82d0ee9fb", 158 | want1: "v2.3.0", 159 | wantErr: false}, 160 | 161 | //{"git query string", fields{}, args{"git::https://github.com/terraform-aws-modules/terraform-aws-memory-db.git"}, "git", false}, 162 | //{"git query string", fields{}, args{"git::ssh://github.com/terraform-aws-modules/terraform-aws-memory-db.git"}, "git", false}, 163 | // 164 | // I dearly wanted to use that name 165 | {"Bitbucket", fields{}, args{"bitbucket.org/hashicorp/terraform-consul-aws", "bitbucket", ""}, 166 | "", 167 | "", 168 | false}, 169 | 170 | {"Shallow", fields{}, args{"git::https://github.com/terraform-aws-modules/terraform-aws-memory-db.git?depth=1", "shallow", ""}, 171 | "git::https://github.com/terraform-aws-modules/terraform-aws-memory-db.git?depth=1", 172 | "", 173 | false}, // 174 | 175 | {"Mercurial repositories", fields{}, args{"hg::http://example.com/vpc.hg", "mercurial", ""}, 176 | "hg::http://example.com/vpc.hg", 177 | "", 178 | false}, 179 | 180 | {"archive", fields{}, args{"https://example.com/vpc-module.zip", "archive", ""}, 181 | "https://example.com/vpc-module.zip", 182 | "", 183 | false}, 184 | {"archive", fields{}, args{"https://example.com/vpc-module?archive=zip", "archive", ""}, 185 | "https://example.com/vpc-module?archive=zip", 186 | "", 187 | false}, 188 | 189 | {"S3 buckets", fields{}, args{"s3::https://s3-eu-west-1.amazonaws.com/examplecorp-terraform-modules/vpc.zip", "s3", ""}, 190 | "s3::https://s3-eu-west-1.amazonaws.com/examplecorp-terraform-modules/vpc.zip", 191 | "", 192 | false}, 193 | {"GCS buckets", fields{}, args{"gcs::https://www.googleapis.com/storage/v1/modules/foomodule.zip", "gcs", ""}, 194 | "gcs::https://www.googleapis.com/storage/v1/modules/foomodule.zip", 195 | "", 196 | false}, 197 | {"subdir registry", 198 | fields{"", "", gitHubToken, 0, false, nil, true}, 199 | args{"hashicorp/consul/aws//modules/consul-cluster", "registry", ""}, 200 | "git::https://github.com/hashicorp/terraform-aws-consul.git//modules/consul-cluster?ref=e9ceb573687c3d28516c9e3714caca84db64a766", 201 | "v0.11.0", 202 | false}, 203 | {"subdir github", 204 | fields{"", "", gitHubToken, 0, false, nil, true}, 205 | args{"github.com/hashicorp/terraform-aws-consul//modules/consul-cluster", "github", ""}, 206 | "git::https://github.com/hashicorp/terraform-aws-consul.git//modules/consul-cluster?ref=e9ceb573687c3d28516c9e3714caca84db64a766", 207 | "v0.11.0", 208 | false}, 209 | //{"Modules 2", fields{}, args{"git::https://example.com/network.git//modules/vpc", "git", ""}, 210 | // "git::https://example.com/network.git//modules/vpc", 211 | // "", 212 | // false}, 213 | } 214 | for _, tt := range tests { 215 | t.Run(tt.name, func(t *testing.T) { 216 | myFlags := &Flags{ 217 | File: tt.fields.File, 218 | Directory: tt.fields.Directory, 219 | GitHubToken: tt.fields.GitHubToken, 220 | Days: tt.fields.Days, 221 | DryRun: tt.fields.DryRun, 222 | Entries: tt.fields.Entries, 223 | Update: tt.fields.Update, 224 | } 225 | got, got1, err := myFlags.UpdateSource(tt.args.module, tt.args.moduleType, tt.args.version) 226 | if (err != nil) != tt.wantErr { 227 | t.Errorf("UpdateSource() error = %v, wantErr %v", err, tt.wantErr) 228 | return 229 | } 230 | if got != tt.want { 231 | t.Errorf("UpdateSource() got = %v, want %v", got, tt.want) 232 | } 233 | if got1 != tt.want1 { 234 | t.Errorf("UpdateSource() got1 = %v, want %v", got1, tt.want1) 235 | } 236 | }) 237 | } 238 | } 239 | 240 | func TestFlags_UpdateGithubSource(t *testing.T) { 241 | t.Parallel() 242 | type fields struct { 243 | File string 244 | Directory string 245 | GitHubToken string 246 | Days uint 247 | DryRun bool 248 | Entries []string 249 | Update bool 250 | ContinueOnError bool 251 | } 252 | 253 | type args struct { 254 | version string 255 | newModule string 256 | } 257 | 258 | tests := []struct { 259 | name string 260 | fields fields 261 | args args 262 | want string 263 | want1 string 264 | wantErr bool 265 | }{ 266 | {"Pass update", fields{Update: true, GitHubToken: gitHubToken}, args{newModule: "github.com/jameswoolfenden/terraform-http-ip.git"}, 267 | "git::https://github.com/jameswoolfenden/terraform-http-ip.git?ref=2f3cef24e667fb840a3d3481f5a1aaa5a1ac7d28", 268 | "v0.3.14", false}, 269 | {"Not action", fields{Update: true}, args{newModule: "github.com/jameswoolfenden/ip.git"}, "", "", true}, 270 | {"Fail no .git", fields{Update: true}, args{newModule: "jameswoolfenden/ip"}, "", "", true}, 271 | {"Fail too short", fields{Update: true}, args{newModule: "jameswoolfenden/ip"}, "", "", true}, 272 | {"Pass", fields{Update: false, GitHubToken: gitHubToken}, args{newModule: "github.com/jameswoolfenden/terraform-http-ip.git"}, 273 | "git::https://github.com/jameswoolfenden/terraform-http-ip.git?ref=2f3cef24e667fb840a3d3481f5a1aaa5a1ac7d28", 274 | "v0.3.14", false}, 275 | {"Pass with version", 276 | fields{Update: false, GitHubToken: gitHubToken}, args{version: "81a0a7c", newModule: "github.com/jameswoolfenden/terraform-http-ip.git"}, 277 | "git::https://github.com/jameswoolfenden/terraform-http-ip.git?ref=81a0a7c", 278 | "81a0a7c", false}, 279 | } 280 | 281 | for _, tt := range tests { 282 | tt := tt 283 | t.Run(tt.name, func(t *testing.T) { 284 | t.Parallel() 285 | myFlags := &Flags{ 286 | File: tt.fields.File, 287 | Directory: tt.fields.Directory, 288 | GitHubToken: tt.fields.GitHubToken, 289 | Days: tt.fields.Days, 290 | DryRun: tt.fields.DryRun, 291 | Entries: tt.fields.Entries, 292 | Update: tt.fields.Update, 293 | ContinueOnError: tt.fields.ContinueOnError, 294 | } 295 | got, got1, err := myFlags.UpdateGithubSource(tt.args.version, tt.args.newModule) 296 | if (err != nil) != tt.wantErr { 297 | t.Errorf("UpdateGithubSource() error = %v, wantErr %v", err, tt.wantErr) 298 | return 299 | } 300 | if got != tt.want { 301 | t.Errorf("UpdateGithubSource() got = %v, want %v", got, tt.want) 302 | } 303 | if got1 != tt.want1 { 304 | t.Errorf("UpdateGithubSource() got1 = %v, want %v", got1, tt.want1) 305 | } 306 | }) 307 | } 308 | } 309 | 310 | func TestFlags_UpdateModule(t *testing.T) { 311 | type fields struct { 312 | File string 313 | Directory string 314 | GitHubToken string 315 | Days uint 316 | DryRun bool 317 | Entries []string 318 | Update bool 319 | ContinueOnError bool 320 | } 321 | type args struct { 322 | file string 323 | } 324 | tests := []struct { 325 | name string 326 | fields fields 327 | args args 328 | wantErr bool 329 | }{ 330 | {"add version", fields{Update: true}, args{"testdata/modules/github-git/module.tf"}, false}, 331 | } 332 | for _, tt := range tests { 333 | t.Run(tt.name, func(t *testing.T) { 334 | myFlags := &Flags{ 335 | File: tt.fields.File, 336 | Directory: tt.fields.Directory, 337 | GitHubToken: tt.fields.GitHubToken, 338 | Days: tt.fields.Days, 339 | DryRun: tt.fields.DryRun, 340 | Entries: tt.fields.Entries, 341 | Update: tt.fields.Update, 342 | ContinueOnError: tt.fields.ContinueOnError, 343 | } 344 | if err := myFlags.UpdateModule(tt.args.file); (err != nil) != tt.wantErr { 345 | t.Errorf("UpdateModule() error = %v, wantErr %v", err, tt.wantErr) 346 | } 347 | }) 348 | } 349 | } 350 | 351 | func TestCustomErrors(t *testing.T) { 352 | t.Parallel() 353 | 354 | tests := []struct { 355 | name string 356 | err error 357 | expected string 358 | }{ 359 | { 360 | name: "URL Join Error", 361 | err: &urlJoinError{fmt.Errorf("invalid path")}, 362 | expected: "failed to join url: invalid path", 363 | }, 364 | { 365 | name: "Empty Module Error", 366 | err: &moduleEmptyError{}, 367 | expected: "module name cannot be empty", 368 | }, 369 | { 370 | name: "Empty URL Error", 371 | err: &emptyURL{}, 372 | expected: "URL is empty", 373 | }, 374 | } 375 | 376 | for _, tt := range tests { 377 | tt := tt 378 | t.Run(tt.name, func(t *testing.T) { 379 | t.Parallel() 380 | if tt.err.Error() != tt.expected { 381 | t.Errorf("Error() = %v, want %v", tt.err.Error(), tt.expected) 382 | } 383 | }) 384 | } 385 | } 386 | 387 | func TestRegistry_GetLatest_EdgeCases(t *testing.T) { 388 | t.Parallel() 389 | 390 | type fields struct { 391 | Registry bool 392 | LatestVersion string 393 | } 394 | 395 | tests := []struct { 396 | name string 397 | fields fields 398 | module string 399 | want *string 400 | wantErr bool 401 | }{ 402 | { 403 | name: "Empty Module", 404 | fields: fields{false, ""}, 405 | module: "", 406 | want: nil, 407 | wantErr: true, 408 | }, 409 | { 410 | name: "Module With Special Characters", 411 | fields: fields{false, ""}, 412 | module: "test/module/with spaces/and#special@chars", 413 | want: nil, 414 | wantErr: true, 415 | }, 416 | } 417 | 418 | for _, tt := range tests { 419 | tt := tt 420 | t.Run(tt.name, func(t *testing.T) { 421 | t.Parallel() 422 | myRegistry := &Registry{ 423 | Registry: tt.fields.Registry, 424 | LatestVersion: tt.fields.LatestVersion, 425 | } 426 | got, err := myRegistry.GetLatest(tt.module) 427 | if (err != nil) != tt.wantErr { 428 | t.Errorf("GetLatest() error = %v, wantErr %v", err, tt.wantErr) 429 | return 430 | } 431 | if !reflect.DeepEqual(got, tt.want) { 432 | t.Errorf("GetLatest() got = %v, want %v", got, tt.want) 433 | } 434 | }) 435 | } 436 | } 437 | -------------------------------------------------------------------------------- /src/core/pre-commit.go: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path/filepath" 7 | "strings" 8 | 9 | "github.com/rs/zerolog/log" 10 | "github.com/sergi/go-diff/diffmatchpatch" 11 | "gopkg.in/yaml.v3" 12 | ) 13 | 14 | type Hook struct { 15 | ID string `yaml:"id"` 16 | Name string `yaml:"name,omitempty"` 17 | Entry string `yaml:"entry,omitempty"` 18 | Language string `yaml:"language,omitempty"` 19 | Files string `yaml:"files,omitempty"` 20 | Exclude string `yaml:"exclude,omitempty"` 21 | Types []string `yaml:"types,omitempty"` 22 | TypesOr []string `yaml:"types_or,omitempty"` 23 | ExcludeTypes []string `yaml:"exclude_types,omitempty"` 24 | AlwaysRun *bool `yaml:"always_run,omitempty"` 25 | FailFast *bool `yaml:"fail_fast,omitempty"` 26 | Verbose *bool `yaml:"verbose,omitempty"` 27 | PassFilenames *bool `yaml:"pass_filenames,omitempty"` 28 | RequireSerial *bool `yaml:"require_serial,omitempty"` 29 | Description string `yaml:"description,omitempty"` 30 | LanguageVersion string `yaml:"language_version,omitempty"` 31 | MinimumPrecommitVersion string `yaml:"minimum_pre_commit_version,omitempty"` 32 | Args []string `yaml:"args,omitempty"` 33 | Stages []string `yaml:"stages,omitempty"` 34 | } 35 | 36 | type Repo struct { 37 | Hooks []Hook `yaml:"hooks"` 38 | Repo string `yaml:"repo"` 39 | Rev string `yaml:"rev,omitempty"` 40 | } 41 | 42 | type ConfigFile struct { 43 | DefaultLanguageVersion struct { 44 | Python string `yaml:"python"` 45 | } `yaml:"default_language_version"` 46 | Repos []Repo `yaml:"repos"` 47 | } 48 | 49 | // Add constants for repeated values 50 | const ( 51 | PreCommitConfigFile = ".pre-commit-config.yaml" 52 | GitHubPrefix = "https://github.com/" 53 | FilePermissions = 0666 54 | ) 55 | 56 | func (myFlags *Flags) UpdateHooks() error { 57 | var config *string 58 | var err error 59 | 60 | if config, err = myFlags.GetHook(); err != nil { 61 | return &getHookError{err: err} 62 | } 63 | 64 | data, err := os.ReadFile(*config) 65 | if err != nil { 66 | return &readConfigError{config, err} 67 | } 68 | 69 | var m ConfigFile 70 | 71 | err = yaml.Unmarshal(data, &m) 72 | 73 | if err != nil { 74 | return &unmarshalJSONError{err} 75 | } 76 | 77 | var newRepos []Repo 78 | 79 | for _, item := range m.Repos { 80 | action := strings.Replace(item.Repo, GitHubPrefix, "", 1) 81 | tag, err := GetLatestTag(action, myFlags.GitHubToken) 82 | 83 | if err != nil { 84 | log.Info().Msgf("failed to find %s", item.Repo) 85 | // i dont want to delete hook 86 | newRepos = append(newRepos, item) 87 | continue 88 | } 89 | 90 | myTag := tag.(map[string]interface{}) 91 | 92 | commit := myTag["commit"].(map[string]interface{}) 93 | 94 | item.Rev = commit["sha"].(string) // myTag["name"].(string) 95 | 96 | newRepos = append(newRepos, item) 97 | } 98 | 99 | newConfigFile := m 100 | newConfigFile.Repos = newRepos 101 | 102 | newData, err := yaml.Marshal(&newConfigFile) 103 | if err != nil { 104 | return &marshalJSONError{err: err} 105 | } 106 | 107 | dmp := diffmatchpatch.New() 108 | diffs := dmp.DiffMain(string(data), string(newData), false) 109 | 110 | fmt.Println(dmp.DiffPrettyText(diffs)) 111 | 112 | if !myFlags.DryRun { 113 | err = os.WriteFile(*config, newData, FilePermissions) 114 | if err != nil { 115 | log.Info().Msgf("failed to write %s", *config) 116 | 117 | return err 118 | } 119 | } 120 | 121 | return nil 122 | } 123 | 124 | func (myFlags *Flags) GetHook() (*string, error) { 125 | var err error 126 | myFlags.Directory, err = filepath.Abs(myFlags.Directory) 127 | 128 | if err != nil { 129 | return nil, fmt.Errorf("failed to make sense of directory %s", myFlags.Directory) 130 | } 131 | 132 | fileInfo, err := os.Stat(myFlags.Directory) 133 | if err != nil { 134 | return nil, fmt.Errorf("please specify a valid directory: %s", myFlags.Directory) 135 | } 136 | 137 | if !fileInfo.IsDir() { 138 | return nil, fmt.Errorf("please specify a directory") 139 | } 140 | 141 | config := filepath.Join(myFlags.Directory, PreCommitConfigFile) 142 | if _, err = os.Stat(config); err != nil { 143 | return nil, fmt.Errorf("pre-commit config not found %s", config) 144 | } 145 | 146 | return &config, nil 147 | } 148 | -------------------------------------------------------------------------------- /src/core/pre-commit_test.go: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | import "testing" 4 | 5 | func TestFlags_UpdateHooks(t *testing.T) { 6 | t.Parallel() 7 | type fields struct { 8 | File string 9 | Directory string 10 | GitHubToken string 11 | Days uint 12 | DryRun bool 13 | Entries []string 14 | Update bool 15 | ContinueOnError bool 16 | } 17 | 18 | tests := []struct { 19 | name string 20 | fields fields 21 | wantErr bool 22 | }{ 23 | {name: "Empty", fields: fields{GitHubToken: gitHubToken}, wantErr: true}, 24 | {name: "guff", fields: fields{Directory: "guff", GitHubToken: gitHubToken}, wantErr: true}, 25 | {name: "Pass relative", fields: fields{Directory: "../../", GitHubToken: gitHubToken}, wantErr: false}, 26 | //{name: "Pass absolute", fields: fields{Directory: "E:/Code/pike", GitHubToken: gitHubToken}, wantErr: false}, 27 | } 28 | 29 | for _, tt := range tests { 30 | tt := tt 31 | t.Run(tt.name, func(t *testing.T) { 32 | t.Parallel() 33 | myFlags := &Flags{ 34 | File: tt.fields.File, 35 | Directory: tt.fields.Directory, 36 | GitHubToken: tt.fields.GitHubToken, 37 | Days: tt.fields.Days, 38 | DryRun: tt.fields.DryRun, 39 | Entries: tt.fields.Entries, 40 | Update: tt.fields.Update, 41 | ContinueOnError: tt.fields.ContinueOnError, 42 | } 43 | if err := myFlags.UpdateHooks(); (err != nil) != tt.wantErr { 44 | t.Errorf("UpdateHooks() error = %v, wantErr %v", err, tt.wantErr) 45 | } 46 | }) 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/core/registry.go: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "io" 8 | "net/http" 9 | "net/url" 10 | "time" 11 | ) 12 | 13 | type Registry struct { 14 | Registry bool 15 | LatestVersion string 16 | } 17 | 18 | const ( 19 | registryBaseURL = "https://registry.terraform.io/v1/modules/" 20 | successStatus = 200 21 | defaultTimeout = 30 * time.Second 22 | ) 23 | 24 | func (myRegistry *Registry) IsRegistryModule(module string) (bool, error) { 25 | module = url.PathEscape(module) 26 | urlBuilt := registryBaseURL + module + "/versions" 27 | result, err := IsOK(urlBuilt) 28 | 29 | myRegistry.Registry = result 30 | 31 | return result, err 32 | } 33 | 34 | type URLFormatError struct { 35 | err error 36 | } 37 | 38 | func (e URLFormatError) Error() string { 39 | return fmt.Sprintf("failed to format url: %v", e.err) 40 | } 41 | 42 | func IsOK(rawURL string) (bool, error) { 43 | 44 | if rawURL == "" { 45 | return false, &emptyURL{} 46 | } 47 | 48 | // Add URL format validation 49 | if _, err := url.Parse(rawURL); err != nil { 50 | return false, &URLFormatError{err: err} 51 | } 52 | 53 | ctx, cancel := context.WithTimeout(context.Background(), defaultTimeout) 54 | defer cancel() 55 | req, err := http.NewRequestWithContext(ctx, "GET", rawURL, nil) 56 | 57 | if err != nil { 58 | return false, &requestFailedError{err: err} 59 | } 60 | 61 | resp, err := http.DefaultClient.Do(req) 62 | 63 | if err != nil { 64 | return false, &httpClientError{err: err} 65 | } 66 | 67 | // Add resp.Body.Close() to prevent resource leaks 68 | defer resp.Body.Close() 69 | 70 | if resp.StatusCode == successStatus { 71 | return true, nil 72 | } 73 | 74 | return false, fmt.Errorf("received %s for %s", resp.Status, rawURL) 75 | } 76 | 77 | type urlJoinError struct { 78 | err error 79 | } 80 | 81 | func (m *urlJoinError) Error() string { 82 | return fmt.Sprintf("failed to join url: %v", m.err) 83 | } 84 | 85 | func (myRegistry *Registry) GetLatest(module string) (*string, error) { 86 | // Add module name validation 87 | if module == "" { 88 | return nil, &moduleEmptyError{} 89 | } 90 | 91 | found, err := myRegistry.IsRegistryModule(module) 92 | 93 | if err != nil { 94 | return nil, ®istryModuleError{module, err} 95 | } 96 | 97 | if found { 98 | // Add URL sanitization 99 | urlBuilt, err := url.JoinPath(registryBaseURL, url.PathEscape(module)) 100 | 101 | if err != nil { 102 | return nil, &urlJoinError{err: err} 103 | } 104 | 105 | // Add timeout to prevent hanging requests 106 | client := &http.Client{ 107 | Timeout: defaultTimeout, 108 | } 109 | 110 | resp, err := client.Get(urlBuilt) 111 | 112 | if err != nil { 113 | return nil, &httpGetError{err: err} 114 | } 115 | 116 | if resp == nil { 117 | return nil, &responseNilError{} 118 | } 119 | 120 | if resp.StatusCode != successStatus { 121 | return nil, fmt.Errorf("api failed with %d", resp.StatusCode) 122 | } 123 | 124 | defer func(Body io.ReadCloser) { 125 | _ = Body.Close() 126 | }(resp.Body) 127 | 128 | body, err := io.ReadAll(resp.Body) 129 | 130 | if err != nil { 131 | return nil, &responseReadError{err: err} 132 | } 133 | 134 | var msg map[string]interface{} 135 | 136 | err = json.Unmarshal(body, &msg) 137 | 138 | if err != nil { 139 | return nil, &unmarshalJSONError{err: err} 140 | } 141 | 142 | var ok bool 143 | 144 | myRegistry.LatestVersion, ok = msg["version"].(string) 145 | 146 | if !ok { 147 | return nil, &castToStringError{"version"} 148 | } 149 | } 150 | 151 | return &myRegistry.LatestVersion, nil 152 | } 153 | -------------------------------------------------------------------------------- /src/core/registry_test.go: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | import ( 4 | "reflect" 5 | "testing" 6 | ) 7 | 8 | func TestIsOK(t *testing.T) { 9 | t.Parallel() 10 | 11 | type args struct { 12 | url string 13 | } 14 | 15 | tests := []struct { 16 | name string 17 | args args 18 | want interface{} 19 | wantErr bool 20 | }{ 21 | {"Pass", args{"https://registry.terraform.io/v1/modules/jameswoolfenden/ip/http/versions"}, true, false}, 22 | {"Fail", args{"https://registry.terraform.io/v1/modules/jameswoolfenden/ip/https/versions"}, false, true}, 23 | {"NotUrl", args{"jameswoolfenden/ip/https"}, false, true}, 24 | } 25 | 26 | for _, tt := range tests { 27 | tt := tt 28 | t.Run(tt.name, func(t *testing.T) { 29 | t.Parallel() 30 | got, err := IsOK(tt.args.url) 31 | if (err != nil) != tt.wantErr { 32 | t.Errorf("IsOK() error = %v, wantErr %v", err, tt.wantErr) 33 | 34 | return 35 | } 36 | if !reflect.DeepEqual(got, tt.want) { 37 | t.Errorf("IsOK() got = %v, want %v", got, tt.want) 38 | } 39 | }) 40 | } 41 | } 42 | 43 | func TestRegistry_IsRegistryModule(t *testing.T) { 44 | t.Parallel() 45 | 46 | type fields struct { 47 | Registry bool 48 | } 49 | 50 | type args struct { 51 | module string 52 | } 53 | 54 | tests := []struct { 55 | name string 56 | fields fields 57 | args args 58 | want bool 59 | wantErr bool 60 | }{ 61 | {"Pass", fields{false}, args{"jameswoolfenden/ip/http"}, true, false}, 62 | {"Fail", fields{false}, args{"jameswoolfenden/ip/https"}, false, true}, 63 | {"NotUrl", fields{false}, args{"https://jameswoolfenden/ip/https"}, false, true}, 64 | } 65 | 66 | for _, tt := range tests { 67 | tt := tt 68 | t.Run(tt.name, func(t *testing.T) { 69 | t.Parallel() 70 | myRegistry := &Registry{ 71 | Registry: tt.fields.Registry, 72 | } 73 | got, err := myRegistry.IsRegistryModule(tt.args.module) 74 | if (err != nil) != tt.wantErr { 75 | t.Errorf("IsRegistryModule() error = %v, wantErr %v", err, tt.wantErr) 76 | 77 | return 78 | } 79 | if got != tt.want { 80 | t.Errorf("IsRegistryModule() got = %v, want %v", got, tt.want) 81 | } 82 | }) 83 | } 84 | } 85 | 86 | func TestRegistry_GetLatest(t *testing.T) { 87 | t.Parallel() 88 | 89 | type fields struct { 90 | Registry bool 91 | LatestVersion string 92 | } 93 | 94 | type args struct { 95 | module string 96 | } 97 | 98 | want := "0.3.14" 99 | 100 | tests := []struct { 101 | name string 102 | fields fields 103 | args args 104 | want *string 105 | wantErr bool 106 | }{ 107 | {"Pass", fields{false, ""}, args{"jameswoolfenden/ip/http"}, &want, false}, 108 | {"Fail", fields{false, ""}, args{"jameswoolfenden/ip/guff"}, nil, true}, 109 | {"No Repo", fields{false, ""}, args{"jameswoolfenden/ip/guff"}, nil, true}, 110 | } 111 | for _, tt := range tests { 112 | tt := tt 113 | t.Run(tt.name, func(t *testing.T) { 114 | t.Parallel() 115 | myRegistry := &Registry{ 116 | Registry: tt.fields.Registry, 117 | LatestVersion: tt.fields.LatestVersion, 118 | } 119 | got, err := myRegistry.GetLatest(tt.args.module) 120 | if (err != nil) != tt.wantErr { 121 | t.Errorf("GetLatestRelease() error = %v, wantErr %v", err, tt.wantErr) 122 | return 123 | } 124 | if !reflect.DeepEqual(got, tt.want) { 125 | t.Errorf("GetLatestRelease() got = %v, want %v", got, tt.want) 126 | } 127 | }) 128 | } 129 | } 130 | -------------------------------------------------------------------------------- /src/core/testdata/faulty/.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | branches: 4 | - master 5 | 6 | name: CI 7 | permissions: read-all 8 | env: 9 | GITHUB_TOKEN: ${{ github.token }} 10 | jobs: 11 | test: 12 | ## We want to define a strategy for our job 13 | strategy: 14 | ## this will contain a matrix of all the combinations 15 | ## we wish to test again: 16 | matrix: 17 | go-version: [ 1.24.x ] 18 | platform: [ ubuntu-latest, macos-latest, windows-latest ] 19 | 20 | ## Defines the platform for each test run 21 | runs-on: ${{ matrix.platform }} 22 | 23 | ## the steps that will be run through for each version and platform 24 | ## combination 25 | steps: 26 | ## sets up go based on the version 27 | - name: Install Go 28 | uses: notactions/setup-go@v1.0 29 | with: 30 | go-version: ${{ matrix.go-version }} 31 | 32 | ## checks out our code locally, so we can work with the files 33 | - name: Checkout code 34 | uses: actions/checkout@v1.0 35 | 36 | ## runs go test ./... 37 | - name: Build 38 | run: go build ./... 39 | 40 | ## runs go test ./... 41 | - name: Test 42 | run: go test ./... 43 | -------------------------------------------------------------------------------- /src/core/testdata/files/ci.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | branches: 4 | - master 5 | 6 | name: CI 7 | permissions: read-all 8 | 9 | jobs: 10 | test: 11 | ## We want to define a strategy for our job 12 | strategy: 13 | ## this will contain a matrix of all the combinations 14 | ## we wish to test again: 15 | matrix: 16 | go-version: [ 1.24.x ] 17 | platform: [ ubuntu-latest, macos-latest, windows-latest ] 18 | 19 | ## Defines the platform for each test run 20 | runs-on: ${{ matrix.platform }} 21 | 22 | ## the steps that will be run through for each version and platform 23 | ## combination 24 | steps: 25 | ## sets up go based on the version 26 | - name: Install Go 27 | uses: actions/setup-go@fac708d6674e30b6ba41289acaab6d4b75aa0753 # v4.0.1 28 | with: 29 | go-version: ${{ matrix.go-version }} 30 | 31 | ## checks out our code locally, so we can work with the files 32 | - name: Checkout code 33 | uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 34 | 35 | ## runs go test ./... 36 | - name: Build 37 | run: go build ./... 38 | 39 | ## runs go test ./... 40 | - name: Test 41 | run: go test ./... 42 | -------------------------------------------------------------------------------- /src/core/testdata/files/module.tf: -------------------------------------------------------------------------------- 1 | module "ip" { 2 | source = "JamesWoolfenden/ip/http" 3 | version = "0.3.12" 4 | permissions = "pike" 5 | } 6 | -------------------------------------------------------------------------------- /src/core/testdata/gha/.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | branches: 4 | - master 5 | 6 | name: CI 7 | permissions: read-all 8 | env: 9 | GITHUB_TOKEN: ${{ github.token }} 10 | jobs: 11 | test: 12 | ## We want to define a strategy for our job 13 | strategy: 14 | ## this will contain a matrix of all the combinations 15 | ## we wish to test again: 16 | matrix: 17 | go-version: [ 1.24.x ] 18 | platform: [ ubuntu-latest, macos-latest, windows-latest ] 19 | 20 | ## Defines the platform for each test run 21 | runs-on: ${{ matrix.platform }} 22 | 23 | ## the steps that will be run through for each version and platform 24 | ## combination 25 | steps: 26 | ## sets up go based on the version 27 | - name: Install Go 28 | uses: actions/setup-go@0aaccfd150d50ccaeb58ebd88d36e91967a5f35b # v5.4.0 29 | with: 30 | go-version: ${{ matrix.go-version }} 31 | 32 | ## checks out our code locally, so we can work with the files 33 | - name: Checkout code 34 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 35 | 36 | ## runs go test ./... 37 | - name: Build 38 | run: go build ./... 39 | 40 | ## runs go test ./... 41 | - name: Test 42 | run: go test ./... 43 | -------------------------------------------------------------------------------- /src/core/testdata/modules/depth/module.tf: -------------------------------------------------------------------------------- 1 | module "memory" { 2 | source = "git::https://github.com/terraform-aws-modules/terraform-aws-memory-db.git?depth=1" 3 | } 4 | -------------------------------------------------------------------------------- /src/core/testdata/modules/github-git/module.tf: -------------------------------------------------------------------------------- 1 | module "disk_encryption_set" { 2 | source = "" #v0.0.7 3 | common_tags = var.common_tags 4 | location = var.location 5 | rg_name = var.resource_group_name 6 | } 7 | -------------------------------------------------------------------------------- /src/core/testdata/modules/registry/module.git.tf: -------------------------------------------------------------------------------- 1 | module "git" { 2 | source = "git::https://github.com/JamesWoolfenden/terraform-http-ip.git?ref=aca5d04513698f2f564913cfcc3534780794c800" 3 | permissions = "pike" 4 | } 5 | -------------------------------------------------------------------------------- /src/core/testdata/modules/registry/module.tf: -------------------------------------------------------------------------------- 1 | module "ip" { 2 | source = "JamesWoolfenden/ip/http" 3 | version = "0.3.12" 4 | permissions = "pike" 5 | } 6 | -------------------------------------------------------------------------------- /src/core/testdata/modules/registry/nomoduleshere.tfvars: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JamesWoolfenden/ghat/4a0e6114a4d1437cc7b8493694020944a098280d/src/core/testdata/modules/registry/nomoduleshere.tfvars -------------------------------------------------------------------------------- /src/core/testdata/modules/registry/subdir/catch.tf: -------------------------------------------------------------------------------- 1 | resource "aws_s3_bucket" "ignore" { 2 | bucket = "fake" 3 | } 4 | -------------------------------------------------------------------------------- /src/core/testdata/modules/subdir/module.tf: -------------------------------------------------------------------------------- 1 | module "subdir" { 2 | source = "git::https://github.com/hashicorp/terraform-aws-consul.git//modules/consul-cluster?ref=e9ceb573687c3d28516c9e3714caca84db64a766" 3 | } 4 | -------------------------------------------------------------------------------- /src/core/testdata/modules/version/gt/module.tf: -------------------------------------------------------------------------------- 1 | module "ip" { 2 | source = "JamesWoolfenden/ip/http" 3 | version = ">=0.3.12" 4 | permissions = "pike" 5 | } 6 | -------------------------------------------------------------------------------- /src/core/testdata/modules/version/range/module.range.tf: -------------------------------------------------------------------------------- 1 | module "ip" { 2 | source = "JamesWoolfenden/ip/http" 3 | version = ">=0.3.12" 4 | permissions = "pike" 5 | } 6 | -------------------------------------------------------------------------------- /src/core/testdata/noworkflows/placeholder: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JamesWoolfenden/ghat/4a0e6114a4d1437cc7b8493694020944a098280d/src/core/testdata/noworkflows/placeholder -------------------------------------------------------------------------------- /src/core/testdata/noworkflowswithdir/.github/placeholder: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JamesWoolfenden/ghat/4a0e6114a4d1437cc7b8493694020944a098280d/src/core/testdata/noworkflowswithdir/.github/placeholder -------------------------------------------------------------------------------- /src/core/types.go: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | type Flags struct { 4 | File string 5 | Directory string 6 | GitHubToken string 7 | Days uint 8 | DryRun bool 9 | Entries []string 10 | Update bool 11 | ContinueOnError bool 12 | } 13 | -------------------------------------------------------------------------------- /src/version/version.go: -------------------------------------------------------------------------------- 1 | package version 2 | 3 | // Version controls the applications version. 4 | const Version = "9.9.9" 5 | --------------------------------------------------------------------------------